From f8c14df0f518d2c923bc2cc9ed57abe9efbe1325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 13:33:29 +0200 Subject: [PATCH 1/8] Add /dld-reindex skill for resolving decision ID collisions Renames locally-added decisions whose IDs clash with the base branch (and, when gh is available, open PRs) before rebasing. Uses git mv to preserve history, patches frontmatter, updates cross-references in other local decisions, and rewrites @decision annotations in local code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/dld-reindex/SKILL.md | 126 +++++++++ .../dld-reindex/scripts/find-collisions.sh | 45 ++++ .../dld-reindex/scripts/list-taken-ids.sh | 59 ++++ .../dld-reindex/scripts/rename-decision.sh | 109 ++++++++ rules/dld-workflow.md | 1 + skills/dld-reindex/SKILL.md | 126 +++++++++ skills/dld-reindex/scripts/find-collisions.sh | 45 ++++ skills/dld-reindex/scripts/list-taken-ids.sh | 59 ++++ skills/dld-reindex/scripts/rename-decision.sh | 109 ++++++++ tests/test_reindex.bats | 255 ++++++++++++++++++ tile.json | 5 +- 11 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/dld-reindex/SKILL.md create mode 100755 .claude/skills/dld-reindex/scripts/find-collisions.sh create mode 100755 .claude/skills/dld-reindex/scripts/list-taken-ids.sh create mode 100755 .claude/skills/dld-reindex/scripts/rename-decision.sh create mode 100644 skills/dld-reindex/SKILL.md create mode 100755 skills/dld-reindex/scripts/find-collisions.sh create mode 100755 skills/dld-reindex/scripts/list-taken-ids.sh create mode 100755 skills/dld-reindex/scripts/rename-decision.sh create mode 100644 tests/test_reindex.bats diff --git a/.claude/skills/dld-reindex/SKILL.md b/.claude/skills/dld-reindex/SKILL.md new file mode 100644 index 0000000..1029234 --- /dev/null +++ b/.claude/skills/dld-reindex/SKILL.md @@ -0,0 +1,126 @@ +--- +name: dld-reindex +description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, regenerates INDEX.md, and optionally commits. +user_invocable: true +--- + +# /dld-reindex — Resolve Decision ID Collisions + +You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically so you don't lose history, annotations, or cross-references. + +**This skill must run before rebasing.** Do not bring remote changes in first — that turns a clean rename into a merge conflict. + +## Interaction style + +Use the `AskUserQuestion` tool when prompting for the commit decision at the end. Everything else is deterministic and runs without user input. + +## Script Paths + +Shared scripts: +``` +.claude/skills/dld-common/scripts/common.sh +.claude/skills/dld-common/scripts/regenerate-index.sh +``` + +Skill-specific scripts: +``` +.claude/skills/dld-reindex/scripts/find-collisions.sh +.claude/skills/dld-reindex/scripts/list-taken-ids.sh +.claude/skills/dld-reindex/scripts/rename-decision.sh +``` + +## Prerequisites + +1. Check that `dld.config.yaml` exists at the repo root. If not, tell the user to run `/dld-init` first and stop. +2. Verify the working tree is clean (`git status --porcelain` empty). If not, ask the user to commit or stash first, then stop. +3. Determine the base branch. Prefer the upstream of the current branch: + ```bash + git rev-parse --abbrev-ref --symbolic-full-name @{upstream} + ``` + If there is no upstream, default to `origin/main`. The user can override by passing a base ref to this skill (e.g. `/dld-reindex origin/develop`). +4. Fetch the base ref so collision detection sees the latest state: + ```bash + git fetch origin + ``` + +## Step 1: Detect collisions + +```bash +bash .claude/skills/dld-reindex/scripts/find-collisions.sh --base "$BASE" +``` + +The output is one line per collision: `\t`. If there is no output, exit with: + +> No ID collisions detected. Safe to rebase onto `$BASE`. + +The same script's underlying `list-taken-ids.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. + +## Step 2: Compute the next free IDs + +Collect the inputs: + +- `TAKEN` — output of `bash .claude/skills/dld-reindex/scripts/list-taken-ids.sh --base "$BASE"`. IDs already used on the base branch (and on open PRs when `gh` is available). +- `LOCAL_KEPT` — local-added decision IDs that are NOT in the collision list. These are IDs the user is keeping; they must not be reassigned to anything else. + +Compute the highest numeric ID across `TAKEN ∪ LOCAL_KEPT`. Assign the next sequential IDs (`max + 1`, `max + 2`, …) to the colliding decisions **in numeric order**, padded with `printf "DL-%03d"`. + +## Step 3: Apply renames + +For each colliding decision (in order), call: + +```bash +bash .claude/skills/dld-reindex/scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path --base "$BASE" +``` + +`rename-decision.sh` does all of the following in one call: + +- `git mv` the file from `DL-OLD.md` to `DL-NEW.md` (preserves rename history). +- Patches the `id:` frontmatter field in the renamed file. +- Rewrites `DL-OLD` mentions inside the renamed file's body (so internal cross-references survive the rename). +- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). It scopes this to the local change set vs the base ref, so it never touches decisions that already existed on the base. +- Rewrites `` `@decision` ``(DL-OLD) annotations to `` `@decision` ``(DL-NEW) in non-decision files that are part of the local change set. + +The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. + +## Step 4: Regenerate INDEX.md + +```bash +bash .claude/skills/dld-common/scripts/regenerate-index.sh +``` + +## Step 5: Ask about committing + +Present the rename table to the user (old ID → new ID, one row per rename) and ask via `AskUserQuestion`: + +> How should I finish this reindex? + +Options (single-select): + +- **Commit and push** — agent runs `git add -A && git commit && git push`. +- **Commit only** — agent runs `git add -A && git commit`, leaves the push to the user. +- **Leave for me** — agent stages nothing further; the working-tree state stays as rename-decision.sh left it (the `git mv`'s already register the renames in the index). + +For "Commit and push" / "Commit only", use this commit message format: + +``` +reindex local decisions: DL-OLD1 -> DL-NEW1, DL-OLD2 -> DL-NEW2 +``` + +For more than three renames, summarize as `reindex N local decisions to avoid base-branch collisions` and put the full table in the body. + +## Step 6: Report + +Print: + +- The renames table (always). +- The stderr note from step 1 if `gh` was skipped (always — even if the user chose to commit). +- The next-step hint: + +> Next step: `git rebase $BASE` + +The skill never rebases or merges — that is always the user's call. + +## Out of scope (v1) + +- **Already-conflicted rebases.** This skill is pre-rebase only. If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. +- **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`. diff --git a/.claude/skills/dld-reindex/scripts/find-collisions.sh b/.claude/skills/dld-reindex/scripts/find-collisions.sh new file mode 100755 index 0000000..46d1ed3 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/find-collisions.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Detect locally-added decision files whose IDs collide with the base branch or open PRs. +# Output: one line per collision: \t +# Exits 0 with no output if there are no collisions. +# Usage: find-collisions.sh [--base ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="origin/main" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +PROJECT_ROOT="$(get_project_root)" +RECORDS_DIR_REL="$(config_get decisions_dir)/records" + +if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "Error: base ref '$BASE' not found." >&2 + exit 1 +fi + +TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE") + +LOCAL_ADDED=$(git -C "$PROJECT_ROOT" diff --name-only --diff-filter=A "$BASE"...HEAD -- "$RECORDS_DIR_REL" 2>/dev/null || true) + +if [[ -z "$LOCAL_ADDED" ]]; then + exit 0 +fi + +while IFS= read -r path; do + [[ -z "$path" ]] && continue + id=$(basename "$path" .md) + if [[ ! "$id" =~ ^DL-[0-9]+$ ]]; then + continue + fi + if grep -qxF "$id" <<<"$TAKEN"; then + printf "%s\t%s\n" "$path" "$id" + fi +done <<< "$LOCAL_ADDED" diff --git a/.claude/skills/dld-reindex/scripts/list-taken-ids.sh b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh new file mode 100755 index 0000000..0b86981 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Output the set of decision IDs taken on the base branch and (best-effort) open PRs. +# One DL-NNN per line, sorted unique. +# Usage: list-taken-ids.sh [--base ] +# Default base: origin/main +# Emits a stderr note when the open-PR scan is skipped (gh missing, repo not on GitHub, +# or gh not authenticated). Exits 0 in all those cases — the base-branch scan is the +# minimum guarantee. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="origin/main" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +PROJECT_ROOT="$(get_project_root)" +DECISIONS_DIR_REL="$(config_get decisions_dir)" +RECORDS_DIR_REL="$DECISIONS_DIR_REL/records" + +if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "Error: base ref '$BASE' not found. Fetch first or pass --base." >&2 + exit 1 +fi + +# Determine whether to scan open PRs via gh. +SKIP_REASON="" +if ! command -v gh >/dev/null 2>&1; then + SKIP_REASON="gh CLI not installed" +elif ! git -C "$PROJECT_ROOT" remote get-url origin 2>/dev/null | grep -qE 'github\.com[:/]'; then + SKIP_REASON="origin is not a GitHub remote" +elif ! gh auth status >/dev/null 2>&1; then + SKIP_REASON="gh not authenticated" +fi + +PR_BASE="${BASE#origin/}" + +{ + # IDs already on the base branch + git -C "$PROJECT_ROOT" ls-tree -r --name-only "$BASE" -- "$RECORDS_DIR_REL" 2>/dev/null \ + | grep -oE 'DL-[0-9]+' || true + + # IDs in files touched by open PRs targeting this base + if [[ -z "$SKIP_REASON" ]]; then + gh pr list --state open --base "$PR_BASE" --json files --limit 100 \ + --jq '.[].files[].path' 2>/dev/null \ + | grep -oE 'DL-[0-9]+' || true + fi +} | sort -u + +if [[ -n "$SKIP_REASON" ]]; then + echo "[dld-reindex] open PRs not scanned: $SKIP_REASON" >&2 +fi diff --git a/.claude/skills/dld-reindex/scripts/rename-decision.sh b/.claude/skills/dld-reindex/scripts/rename-decision.sh new file mode 100755 index 0000000..bf1441c --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/rename-decision.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Rename a locally-added decision from DL-OLD to DL-NEW. +# * git mv the file (preserves rename history). +# * Patch the file's frontmatter id field and rewrite self-references inside it. +# * Update DL-OLD references in OTHER locally-added/modified decision files. +# * Update @decision(DL-OLD) annotations in locally-added/modified non-decision files. +# +# Usage: rename-decision.sh --old DL-OLD --new DL-NEW --path [--base ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +OLD="" +NEW="" +INPUT_PATH="" +BASE="origin/main" + +while [[ $# -gt 0 ]]; do + case "$1" in + --old) OLD="$2"; shift 2 ;; + --new) NEW="$2"; shift 2 ;; + --path) INPUT_PATH="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$OLD" || -z "$NEW" || -z "$INPUT_PATH" ]]; then + echo "Error: --old, --new, and --path are required." >&2 + exit 1 +fi + +if [[ ! "$OLD" =~ ^DL-[0-9]+$ ]] || [[ ! "$NEW" =~ ^DL-[0-9]+$ ]]; then + echo "Error: IDs must match DL-[0-9]+." >&2 + exit 1 +fi + +if [[ "$OLD" == "$NEW" ]]; then + echo "Error: --old and --new are the same." >&2 + exit 1 +fi + +PROJECT_ROOT="$(get_project_root)" +cd "$PROJECT_ROOT" + +if [[ ! -f "$INPUT_PATH" ]]; then + echo "Error: $INPUT_PATH not found." >&2 + exit 1 +fi + +DIR=$(dirname "$INPUT_PATH") +NEW_PATH="$DIR/$NEW.md" + +if [[ -e "$NEW_PATH" ]]; then + echo "Error: $NEW_PATH already exists." >&2 + exit 1 +fi + +# Pick the right sed-in-place flavor. GNU and BSD both accept `-i.bak`; we then drop the backup. +sed_inplace() { + local file="$1"; shift + sed -E -i.bak "$@" "$file" + rm -f "$file.bak" +} + +# Substitute DL-OLD with DL-NEW only when not followed by another digit (avoids +# turning DL-100 into DL-2000 when renaming DL-100 → DL-200, etc.). +substitute_in_file() { + local file="$1" + sed_inplace "$file" \ + -e "s/${OLD}([^0-9])/${NEW}\\1/g" \ + -e "s/${OLD}\$/${NEW}/" +} + +# 1. Rename the file via git mv. +git mv "$INPUT_PATH" "$NEW_PATH" + +# 2. Patch the frontmatter id field and rewrite self-references. +sed_inplace "$NEW_PATH" -e "s/^id:[[:space:]]*${OLD}\$/id: ${NEW}/" +substitute_in_file "$NEW_PATH" + +# 3. Determine the local change set (files added or modified vs the base ref). +CHANGED_FILES=$(git diff --name-only --diff-filter=AM "$BASE"...HEAD 2>/dev/null || true) + +DECISIONS_DIR_REL="$(config_get decisions_dir)" +ANNOTATION_PREFIX="$(config_get annotation_prefix)" + +if [[ -n "$CHANGED_FILES" ]]; then + while IFS= read -r f; do + [[ -z "$f" || ! -f "$f" ]] && continue + # Skip the renamed file itself (already patched in step 2). + [[ "$f" == "$NEW_PATH" ]] && continue + if [[ "$f" == "$DECISIONS_DIR_REL"/* ]]; then + # Other local decision files: rewrite DL-OLD anywhere it appears. + if grep -qE "${OLD}([^0-9]|\$)" "$f" 2>/dev/null; then + substitute_in_file "$f" + fi + else + # Non-decision files: only rewrite @decision(DL-OLD) annotations. + if grep -qF "${ANNOTATION_PREFIX}(${OLD})" "$f" 2>/dev/null; then + sed_inplace "$f" -e "s|${ANNOTATION_PREFIX}\\(${OLD}\\)|${ANNOTATION_PREFIX}(${NEW})|g" + fi + fi + done <<< "$CHANGED_FILES" +fi + +echo "$INPUT_PATH -> $NEW_PATH" diff --git a/rules/dld-workflow.md b/rules/dld-workflow.md index a8c340a..e87ce3a 100644 --- a/rules/dld-workflow.md +++ b/rules/dld-workflow.md @@ -16,3 +16,4 @@ This project uses Decision-Linked Development. Decision records (DL-*.md) live i - Use `/dld-status` for a quick overview of the decision log state - Use `/dld-adjust` to adjust or update existing decisions - Use `/dld-retrofit` to generate decisions from an existing codebase +- Use `/dld-reindex` to resolve decision-ID collisions with the base branch (and open PRs) before rebasing diff --git a/skills/dld-reindex/SKILL.md b/skills/dld-reindex/SKILL.md new file mode 100644 index 0000000..13a5e6a --- /dev/null +++ b/skills/dld-reindex/SKILL.md @@ -0,0 +1,126 @@ +--- +name: dld-reindex +description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, regenerates INDEX.md, and optionally commits. +compatibility: Requires bash and git. Open-PR scanning additionally needs the `gh` CLI authenticated against a GitHub remote — the skill falls back gracefully when unavailable. +--- + +# /dld-reindex — Resolve Decision ID Collisions + +You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically so you don't lose history, annotations, or cross-references. + +**This skill must run before rebasing.** Do not bring remote changes in first — that turns a clean rename into a merge conflict. + +## Interaction style + +Use the `AskUserQuestion` tool when prompting for the commit decision at the end. Everything else is deterministic and runs without user input. + +## Script Paths + +Shared scripts: +``` +../dld-common/scripts/common.sh +../dld-common/scripts/regenerate-index.sh +``` + +Skill-specific scripts: +``` +scripts/find-collisions.sh +scripts/list-taken-ids.sh +scripts/rename-decision.sh +``` + +## Prerequisites + +1. Check that `dld.config.yaml` exists at the repo root. If not, tell the user to run `/dld-init` first and stop. +2. Verify the working tree is clean (`git status --porcelain` empty). If not, ask the user to commit or stash first, then stop. +3. Determine the base branch. Prefer the upstream of the current branch: + ```bash + git rev-parse --abbrev-ref --symbolic-full-name @{upstream} + ``` + If there is no upstream, default to `origin/main`. The user can override by passing a base ref to this skill (e.g. `/dld-reindex origin/develop`). +4. Fetch the base ref so collision detection sees the latest state: + ```bash + git fetch origin + ``` + +## Step 1: Detect collisions + +```bash +bash scripts/find-collisions.sh --base "$BASE" +``` + +The output is one line per collision: `\t`. If there is no output, exit with: + +> No ID collisions detected. Safe to rebase onto `$BASE`. + +The same script's underlying `list-taken-ids.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. + +## Step 2: Compute the next free IDs + +Collect the inputs: + +- `TAKEN` — output of `bash scripts/list-taken-ids.sh --base "$BASE"`. IDs already used on the base branch (and on open PRs when `gh` is available). +- `LOCAL_KEPT` — local-added decision IDs that are NOT in the collision list. These are IDs the user is keeping; they must not be reassigned to anything else. + +Compute the highest numeric ID across `TAKEN ∪ LOCAL_KEPT`. Assign the next sequential IDs (`max + 1`, `max + 2`, …) to the colliding decisions **in numeric order**, padded with `printf "DL-%03d"`. + +## Step 3: Apply renames + +For each colliding decision (in order), call: + +```bash +bash scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path --base "$BASE" +``` + +`rename-decision.sh` does all of the following in one call: + +- `git mv` the file from `DL-OLD.md` to `DL-NEW.md` (preserves rename history). +- Patches the `id:` frontmatter field in the renamed file. +- Rewrites `DL-OLD` mentions inside the renamed file's body (so internal cross-references survive the rename). +- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). It scopes this to the local change set vs the base ref, so it never touches decisions that already existed on the base. +- Rewrites `` `@decision` ``(DL-OLD) annotations to `` `@decision` ``(DL-NEW) in non-decision files that are part of the local change set. + +The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. + +## Step 4: Regenerate INDEX.md + +```bash +bash ../dld-common/scripts/regenerate-index.sh +``` + +## Step 5: Ask about committing + +Present the rename table to the user (old ID → new ID, one row per rename) and ask via `AskUserQuestion`: + +> How should I finish this reindex? + +Options (single-select): + +- **Commit and push** — agent runs `git add -A && git commit && git push`. +- **Commit only** — agent runs `git add -A && git commit`, leaves the push to the user. +- **Leave for me** — agent stages nothing further; the working-tree state stays as rename-decision.sh left it (the `git mv`'s already register the renames in the index). + +For "Commit and push" / "Commit only", use this commit message format: + +``` +reindex local decisions: DL-OLD1 -> DL-NEW1, DL-OLD2 -> DL-NEW2 +``` + +For more than three renames, summarize as `reindex N local decisions to avoid base-branch collisions` and put the full table in the body. + +## Step 6: Report + +Print: + +- The renames table (always). +- The stderr note from step 1 if `gh` was skipped (always — even if the user chose to commit). +- The next-step hint: + +> Next step: `git rebase $BASE` + +The skill never rebases or merges — that is always the user's call. + +## Out of scope (v1) + +- **Already-conflicted rebases.** This skill is pre-rebase only. If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. +- **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`. diff --git a/skills/dld-reindex/scripts/find-collisions.sh b/skills/dld-reindex/scripts/find-collisions.sh new file mode 100755 index 0000000..46d1ed3 --- /dev/null +++ b/skills/dld-reindex/scripts/find-collisions.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Detect locally-added decision files whose IDs collide with the base branch or open PRs. +# Output: one line per collision: \t +# Exits 0 with no output if there are no collisions. +# Usage: find-collisions.sh [--base ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="origin/main" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +PROJECT_ROOT="$(get_project_root)" +RECORDS_DIR_REL="$(config_get decisions_dir)/records" + +if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "Error: base ref '$BASE' not found." >&2 + exit 1 +fi + +TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE") + +LOCAL_ADDED=$(git -C "$PROJECT_ROOT" diff --name-only --diff-filter=A "$BASE"...HEAD -- "$RECORDS_DIR_REL" 2>/dev/null || true) + +if [[ -z "$LOCAL_ADDED" ]]; then + exit 0 +fi + +while IFS= read -r path; do + [[ -z "$path" ]] && continue + id=$(basename "$path" .md) + if [[ ! "$id" =~ ^DL-[0-9]+$ ]]; then + continue + fi + if grep -qxF "$id" <<<"$TAKEN"; then + printf "%s\t%s\n" "$path" "$id" + fi +done <<< "$LOCAL_ADDED" diff --git a/skills/dld-reindex/scripts/list-taken-ids.sh b/skills/dld-reindex/scripts/list-taken-ids.sh new file mode 100755 index 0000000..0b86981 --- /dev/null +++ b/skills/dld-reindex/scripts/list-taken-ids.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Output the set of decision IDs taken on the base branch and (best-effort) open PRs. +# One DL-NNN per line, sorted unique. +# Usage: list-taken-ids.sh [--base ] +# Default base: origin/main +# Emits a stderr note when the open-PR scan is skipped (gh missing, repo not on GitHub, +# or gh not authenticated). Exits 0 in all those cases — the base-branch scan is the +# minimum guarantee. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="origin/main" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +PROJECT_ROOT="$(get_project_root)" +DECISIONS_DIR_REL="$(config_get decisions_dir)" +RECORDS_DIR_REL="$DECISIONS_DIR_REL/records" + +if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "Error: base ref '$BASE' not found. Fetch first or pass --base." >&2 + exit 1 +fi + +# Determine whether to scan open PRs via gh. +SKIP_REASON="" +if ! command -v gh >/dev/null 2>&1; then + SKIP_REASON="gh CLI not installed" +elif ! git -C "$PROJECT_ROOT" remote get-url origin 2>/dev/null | grep -qE 'github\.com[:/]'; then + SKIP_REASON="origin is not a GitHub remote" +elif ! gh auth status >/dev/null 2>&1; then + SKIP_REASON="gh not authenticated" +fi + +PR_BASE="${BASE#origin/}" + +{ + # IDs already on the base branch + git -C "$PROJECT_ROOT" ls-tree -r --name-only "$BASE" -- "$RECORDS_DIR_REL" 2>/dev/null \ + | grep -oE 'DL-[0-9]+' || true + + # IDs in files touched by open PRs targeting this base + if [[ -z "$SKIP_REASON" ]]; then + gh pr list --state open --base "$PR_BASE" --json files --limit 100 \ + --jq '.[].files[].path' 2>/dev/null \ + | grep -oE 'DL-[0-9]+' || true + fi +} | sort -u + +if [[ -n "$SKIP_REASON" ]]; then + echo "[dld-reindex] open PRs not scanned: $SKIP_REASON" >&2 +fi diff --git a/skills/dld-reindex/scripts/rename-decision.sh b/skills/dld-reindex/scripts/rename-decision.sh new file mode 100755 index 0000000..bf1441c --- /dev/null +++ b/skills/dld-reindex/scripts/rename-decision.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Rename a locally-added decision from DL-OLD to DL-NEW. +# * git mv the file (preserves rename history). +# * Patch the file's frontmatter id field and rewrite self-references inside it. +# * Update DL-OLD references in OTHER locally-added/modified decision files. +# * Update @decision(DL-OLD) annotations in locally-added/modified non-decision files. +# +# Usage: rename-decision.sh --old DL-OLD --new DL-NEW --path [--base ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +OLD="" +NEW="" +INPUT_PATH="" +BASE="origin/main" + +while [[ $# -gt 0 ]]; do + case "$1" in + --old) OLD="$2"; shift 2 ;; + --new) NEW="$2"; shift 2 ;; + --path) INPUT_PATH="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$OLD" || -z "$NEW" || -z "$INPUT_PATH" ]]; then + echo "Error: --old, --new, and --path are required." >&2 + exit 1 +fi + +if [[ ! "$OLD" =~ ^DL-[0-9]+$ ]] || [[ ! "$NEW" =~ ^DL-[0-9]+$ ]]; then + echo "Error: IDs must match DL-[0-9]+." >&2 + exit 1 +fi + +if [[ "$OLD" == "$NEW" ]]; then + echo "Error: --old and --new are the same." >&2 + exit 1 +fi + +PROJECT_ROOT="$(get_project_root)" +cd "$PROJECT_ROOT" + +if [[ ! -f "$INPUT_PATH" ]]; then + echo "Error: $INPUT_PATH not found." >&2 + exit 1 +fi + +DIR=$(dirname "$INPUT_PATH") +NEW_PATH="$DIR/$NEW.md" + +if [[ -e "$NEW_PATH" ]]; then + echo "Error: $NEW_PATH already exists." >&2 + exit 1 +fi + +# Pick the right sed-in-place flavor. GNU and BSD both accept `-i.bak`; we then drop the backup. +sed_inplace() { + local file="$1"; shift + sed -E -i.bak "$@" "$file" + rm -f "$file.bak" +} + +# Substitute DL-OLD with DL-NEW only when not followed by another digit (avoids +# turning DL-100 into DL-2000 when renaming DL-100 → DL-200, etc.). +substitute_in_file() { + local file="$1" + sed_inplace "$file" \ + -e "s/${OLD}([^0-9])/${NEW}\\1/g" \ + -e "s/${OLD}\$/${NEW}/" +} + +# 1. Rename the file via git mv. +git mv "$INPUT_PATH" "$NEW_PATH" + +# 2. Patch the frontmatter id field and rewrite self-references. +sed_inplace "$NEW_PATH" -e "s/^id:[[:space:]]*${OLD}\$/id: ${NEW}/" +substitute_in_file "$NEW_PATH" + +# 3. Determine the local change set (files added or modified vs the base ref). +CHANGED_FILES=$(git diff --name-only --diff-filter=AM "$BASE"...HEAD 2>/dev/null || true) + +DECISIONS_DIR_REL="$(config_get decisions_dir)" +ANNOTATION_PREFIX="$(config_get annotation_prefix)" + +if [[ -n "$CHANGED_FILES" ]]; then + while IFS= read -r f; do + [[ -z "$f" || ! -f "$f" ]] && continue + # Skip the renamed file itself (already patched in step 2). + [[ "$f" == "$NEW_PATH" ]] && continue + if [[ "$f" == "$DECISIONS_DIR_REL"/* ]]; then + # Other local decision files: rewrite DL-OLD anywhere it appears. + if grep -qE "${OLD}([^0-9]|\$)" "$f" 2>/dev/null; then + substitute_in_file "$f" + fi + else + # Non-decision files: only rewrite @decision(DL-OLD) annotations. + if grep -qF "${ANNOTATION_PREFIX}(${OLD})" "$f" 2>/dev/null; then + sed_inplace "$f" -e "s|${ANNOTATION_PREFIX}\\(${OLD}\\)|${ANNOTATION_PREFIX}(${NEW})|g" + fi + fi + done <<< "$CHANGED_FILES" +fi + +echo "$INPUT_PATH -> $NEW_PATH" diff --git a/tests/test_reindex.bats b/tests/test_reindex.bats new file mode 100644 index 0000000..37396be --- /dev/null +++ b/tests/test_reindex.bats @@ -0,0 +1,255 @@ +#!/usr/bin/env bats +# Tests for dld-reindex skill scripts. +# Uses a local `main` branch in lieu of `origin/main` so tests don't need a remote. + +load 'test_helper/common' + +REINDEX_DIR="" + +setup() { + setup_flat_project + REINDEX_DIR="$SKILLS_DIR/dld-reindex/scripts" + + # Establish a `main` branch with one decision already on it, then branch off. + create_decision "DL-001" "accepted" + git add -A + git commit --quiet -m "seed main" + git branch -M main + git checkout -b feature --quiet +} + +teardown() { + teardown_project +} + +# --- find-collisions.sh ------------------------------------------------------- + +@test "find-collisions: no output when there are no local additions" { + run bash -c "bash \"$REINDEX_DIR/find-collisions.sh\" --base main 2>/dev/null" + assert_success + assert_output "" +} + +@test "find-collisions: no output when local additions don't collide" { + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "add DL-002" + run bash -c "bash \"$REINDEX_DIR/find-collisions.sh\" --base main 2>/dev/null" + assert_success + assert_output "" +} + +@test "find-collisions: detects a single ID collision" { + # Simulate someone else landing DL-002 on main while we drafted DL-002 too. + git checkout main --quiet + create_decision "DL-002" "accepted" + git add -A + git commit --quiet -m "land DL-002 on main" + git checkout feature --quiet + + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "local DL-002" + + run bash "$REINDEX_DIR/find-collisions.sh" --base main + assert_success + assert_output --partial $'decisions/records/DL-002.md\tDL-002' +} + +@test "find-collisions: ignores files that aren't DL-NNN.md" { + echo "junk" > decisions/records/notes.md + git add -A + git commit --quiet -m "add notes" + run bash -c "bash \"$REINDEX_DIR/find-collisions.sh\" --base main 2>/dev/null" + assert_success + assert_output "" +} + +@test "find-collisions: errors out on unknown base ref" { + run bash "$REINDEX_DIR/find-collisions.sh" --base does/not/exist + assert_failure + assert_output --partial "not found" +} + +# --- list-taken-ids.sh ------------------------------------------------------- + +@test "list-taken-ids: outputs IDs present on the base branch" { + run bash "$REINDEX_DIR/list-taken-ids.sh" --base main + assert_success + assert_output --partial "DL-001" +} + +@test "list-taken-ids: gracefully skips PR scan when not on a GitHub remote" { + # No origin remote at all in this test repo. + run bash "$REINDEX_DIR/list-taken-ids.sh" --base main + assert_success + # stderr note is captured into output by bats when stderr is merged; just + # confirm the command still succeeded with the base-branch IDs. + assert_output --partial "DL-001" +} + +# --- rename-decision.sh ------------------------------------------------------ + +@test "rename-decision: git mv preserves rename history" { + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "local DL-002" + + run bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md \ + --base main + assert_success + + [[ ! -e decisions/records/DL-002.md ]] + [[ -e decisions/records/DL-007.md ]] + + # Stage-side rename detection via git status (RM = rename + modify after frontmatter patch) + run git status --porcelain + assert_output --partial "decisions/records/DL-002.md -> decisions/records/DL-007.md" +} + +@test "rename-decision: patches frontmatter id field" { + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "local DL-002" + + bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md \ + --base main + + run cat decisions/records/DL-007.md + assert_output --partial "id: DL-007" + refute_output --partial "id: DL-002" +} + +@test "rename-decision: rewrites self-references inside the renamed file" { + cat > decisions/records/DL-002.md <<'EOF' +--- +id: DL-002 +title: "Test" +timestamp: 2026-01-15T10:00:00Z +status: proposed +supersedes: [] +amends: [] +tags: [] +references: [] +--- + +This decision DL-002 references itself in the body. +EOF + git add -A + git commit --quiet -m "local DL-002" + + bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md \ + --base main + + run cat decisions/records/DL-007.md + assert_output --partial "This decision DL-007 references itself" + refute_output --partial "DL-002" +} + +@test "rename-decision: rewrites @decision annotations in code (local changes only)" { + create_decision "DL-002" "proposed" + mkdir -p src + cat > src/auth.py <<'EOF' +# @decision(DL-002) +def login(): pass +EOF + git add -A + git commit --quiet -m "local DL-002 + code" + + bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md \ + --base main + + run cat src/auth.py + assert_output --partial "@decision(DL-007)" + refute_output --partial "@decision(DL-002)" +} + +@test "rename-decision: rewrites cross-refs in other local decisions" { + create_decision "DL-002" "proposed" + cat > decisions/records/DL-003.md <<'EOF' +--- +id: DL-003 +title: "Builds on DL-002" +timestamp: 2026-01-15T10:00:00Z +status: proposed +supersedes: [] +amends: [DL-002] +tags: [] +references: [] +--- + +This decision builds on DL-002. +EOF + git add -A + git commit --quiet -m "local DL-002 and DL-003" + + bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md \ + --base main + + run cat decisions/records/DL-003.md + assert_output --partial "amends: [DL-007]" + assert_output --partial "builds on DL-007" + refute_output --partial "DL-002" +} + +@test "rename-decision: digit-aware substitution does not rewrite DL-100 inside DL-1000" { + create_decision "DL-100" "proposed" + # Hand-craft a body containing DL-1000 mention. + cat > decisions/records/DL-100.md <<'EOF' +--- +id: DL-100 +title: "Test" +timestamp: 2026-01-15T10:00:00Z +status: proposed +supersedes: [] +amends: [] +tags: [] +references: [] +--- + +This refers to DL-100 and also unrelated DL-1000. +EOF + git add -A + git commit --quiet -m "local DL-100" + + bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-100 --new DL-200 \ + --path decisions/records/DL-100.md \ + --base main + + run cat decisions/records/DL-200.md + assert_output --partial "refers to DL-200 and also unrelated DL-1000" +} + +@test "rename-decision: fails when target file already exists" { + create_decision "DL-002" "proposed" + create_decision "DL-007" "proposed" + git add -A + git commit --quiet -m "two locals" + + run bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md \ + --base main + assert_failure + assert_output --partial "already exists" +} + +@test "rename-decision: fails on invalid IDs" { + run bash "$REINDEX_DIR/rename-decision.sh" \ + --old DL-002 --new "not-an-id" \ + --path decisions/records/DL-002.md \ + --base main + assert_failure + assert_output --partial "DL-[0-9]+" +} diff --git a/tile.json b/tile.json index 8bf8634..d19dae7 100644 --- a/tile.json +++ b/tile.json @@ -1,6 +1,6 @@ { "name": "dld-kit/dld", - "version": "0.6.0", + "version": "0.7.0", "summary": "Decision-Linked Development (DLD) — a workflow for recording, linking, and maintaining development decisions alongside code. Skills for planning, recording, implementing, auditing, and documenting decisions via @decision annotations.", "private": false, "steering": { @@ -44,6 +44,9 @@ }, "dld-adjust": { "path": "skills/dld-adjust/SKILL.md" + }, + "dld-reindex": { + "path": "skills/dld-reindex/SKILL.md" } } } From a2e47558a56846c1007fd3d5370ea4238637f2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 13:37:40 +0200 Subject: [PATCH 2/8] Drop (v1) tag from /dld-reindex out-of-scope section Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/dld-reindex/SKILL.md | 2 +- skills/dld-reindex/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/dld-reindex/SKILL.md b/.claude/skills/dld-reindex/SKILL.md index 1029234..73cf457 100644 --- a/.claude/skills/dld-reindex/SKILL.md +++ b/.claude/skills/dld-reindex/SKILL.md @@ -120,7 +120,7 @@ Print: The skill never rebases or merges — that is always the user's call. -## Out of scope (v1) +## Out of scope - **Already-conflicted rebases.** This skill is pre-rebase only. If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. - **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`. diff --git a/skills/dld-reindex/SKILL.md b/skills/dld-reindex/SKILL.md index 13a5e6a..787e340 100644 --- a/skills/dld-reindex/SKILL.md +++ b/skills/dld-reindex/SKILL.md @@ -120,7 +120,7 @@ Print: The skill never rebases or merges — that is always the user's call. -## Out of scope (v1) +## Out of scope - **Already-conflicted rebases.** This skill is pre-rebase only. If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. - **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`. From a7127242e597b8269842a424ac05ce560226bc0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 13:58:39 +0200 Subject: [PATCH 3/8] Make /dld-reindex regenerate a rebase-clean INDEX.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit regenerate-index.sh gains an --include-base flag that merges in decision rows from a base ref for any DL-*.md the local branch doesn't have. The reindex SKILL now passes --include-base so the pre-rebase INDEX.md contains both renamed-local rows and base-branch rows that landed while the branch was diverged — making the subsequent rebase auto-merge instead of conflicting on INDEX.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dld-common/scripts/regenerate-index.sh | 98 ++++++++++++++----- .claude/skills/dld-reindex/SKILL.md | 4 +- skills/dld-common/scripts/regenerate-index.sh | 98 ++++++++++++++----- skills/dld-reindex/SKILL.md | 4 +- tests/test_regenerate_index.bats | 53 ++++++++++ 5 files changed, 205 insertions(+), 52 deletions(-) diff --git a/.claude/skills/dld-common/scripts/regenerate-index.sh b/.claude/skills/dld-common/scripts/regenerate-index.sh index 085705e..64bd2df 100755 --- a/.claude/skills/dld-common/scripts/regenerate-index.sh +++ b/.claude/skills/dld-common/scripts/regenerate-index.sh @@ -1,29 +1,60 @@ #!/usr/bin/env bash # Regenerate decisions/INDEX.md from all decision files. # Reads YAML frontmatter from each DL-*.md file and builds a markdown table. +# +# Optional --include-base : also include decision files from the given git +# ref (e.g. origin/main) that are not present in the working tree. Used by +# /dld-reindex so a pre-rebase INDEX.md contains both renamed-local rows and +# base-branch rows the local commit hasn't seen yet — the rebase then auto-merges. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" +INCLUDE_BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --include-base) INCLUDE_BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + DECISIONS_DIR="$(get_decisions_dir)" RECORDS_DIR="$(get_records_dir)" MODE="$(get_mode)" INDEX_FILE="$DECISIONS_DIR/INDEX.md" +PROJECT_ROOT="$(get_project_root)" +RECORDS_DIR_REL="${RECORDS_DIR#"$PROJECT_ROOT"/}" if [[ ! -d "$RECORDS_DIR" ]]; then echo "Error: records directory not found at $RECORDS_DIR" >&2 exit 1 fi -# Extract a frontmatter field from a decision file -# Usage: extract_field +if [[ -n "$INCLUDE_BASE" ]]; then + if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$INCLUDE_BASE^{commit}" >/dev/null; then + echo "Error: --include-base ref '$INCLUDE_BASE' not found." >&2 + exit 1 + fi +fi + +# Read the body of a "source spec" — either local: or base:. +read_source() { + local source="$1" + case "$source" in + base:*) git -C "$PROJECT_ROOT" show "$INCLUDE_BASE:${source#base:}" 2>/dev/null ;; + local:*) cat "${source#local:}" ;; + *) cat "$source" ;; # backward compat + esac +} + +# Extract a frontmatter field from a source spec. extract_field() { - local file="$1" + local source="$1" local field="$2" - # Read between --- markers, find the field - sed -n '/^---$/,/^---$/p' "$file" \ + read_source "$source" \ + | sed -n '/^---$/,/^---$/p' \ | grep "^${field}:" \ | head -1 \ | sed "s/^${field}:[[:space:]]*//" \ @@ -31,26 +62,43 @@ extract_field() { | sed "s/^'\(.*\)'$/\1/" } -# Extract array field as comma-separated string -# Usage: extract_array_field +# Extract array field as comma-separated string. extract_array_field() { - local file="$1" + local source="$1" local field="$2" local raw - raw=$(extract_field "$file" "$field") - # Handle YAML inline array: [tag1, tag2, tag3] + raw=$(extract_field "$source" "$field") echo "$raw" | sed 's/^\[//;s/\]$//;s/,[[:space:]]*/,/g;s/,/, /g' } -# Collect all decision files -# Sort by numeric ID descending: extract ID number, sort, reconstruct -DECISION_FILES=$(find "$RECORDS_DIR" -name 'DL-*.md' -type f \ - | awk -F/ '{file=$0; basename=$NF; gsub(/^DL-/,"",basename); gsub(/\.md$/,"",basename); print basename "\t" file}' \ - | sort -n -r \ - | cut -f2) +# Build the source list as \t lines. +# Local working-tree files first. +SOURCES="" +while IFS= read -r f; do + [[ -z "$f" ]] && continue + bn=$(basename "$f" .md) + num="${bn#DL-}" + SOURCES+="$num"$'\t'"local:$f"$'\n' +done < <(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null) + +# Then base-only files (skip any whose basename already appears locally). +if [[ -n "$INCLUDE_BASE" ]]; then + LOCAL_BASENAMES=$(find "$RECORDS_DIR" -name 'DL-*.md' -type f -exec basename {} \; 2>/dev/null | sort -u) + while IFS= read -r p; do + [[ -z "$p" ]] && continue + bn=$(basename "$p") + if grep -qxF "$bn" <<<"$LOCAL_BASENAMES"; then + continue + fi + num=$(echo "$bn" | sed 's/^DL-//;s/\.md$//') + SOURCES+="$num"$'\t'"base:$p"$'\n' + done < <(git -C "$PROJECT_ROOT" ls-tree -r --name-only "$INCLUDE_BASE" -- "$RECORDS_DIR_REL" 2>/dev/null | grep -E 'DL-[0-9]+\.md$' || true) +fi + +# Strip the trailing newline and sort by numeric ID descending. +SORTED_SOURCES=$(printf '%s' "$SOURCES" | sort -t$'\t' -k1,1 -n -r | cut -f2) -if [[ -z "$DECISION_FILES" ]]; then - # Write empty index +if [[ -z "$SORTED_SOURCES" ]]; then { echo "# Decision Log" echo "" @@ -66,7 +114,6 @@ if [[ -z "$DECISION_FILES" ]]; then exit 0 fi -# Build the index { echo "# Decision Log" echo "" @@ -78,14 +125,15 @@ fi echo "|----|-------|--------|------|" fi - echo "$DECISION_FILES" | while IFS= read -r file; do - id=$(extract_field "$file" "id") - title=$(extract_field "$file" "title") - status=$(extract_field "$file" "status") - tags=$(extract_array_field "$file" "tags") + echo "$SORTED_SOURCES" | while IFS= read -r source; do + [[ -z "$source" ]] && continue + id=$(extract_field "$source" "id") + title=$(extract_field "$source" "title") + status=$(extract_field "$source" "status") + tags=$(extract_array_field "$source" "tags") if [[ "$MODE" == "namespaced" ]]; then - namespace=$(extract_field "$file" "namespace") + namespace=$(extract_field "$source" "namespace") echo "| $id | $title | $status | $namespace | $tags |" else echo "| $id | $title | $status | $tags |" diff --git a/.claude/skills/dld-reindex/SKILL.md b/.claude/skills/dld-reindex/SKILL.md index 73cf457..3e69fce 100644 --- a/.claude/skills/dld-reindex/SKILL.md +++ b/.claude/skills/dld-reindex/SKILL.md @@ -84,8 +84,10 @@ The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite ## Step 4: Regenerate INDEX.md +Pass `--include-base` so the regenerated INDEX merges in decisions that exist on the base branch but not yet locally. Without this flag, the regenerated INDEX would only contain rows the branch knows about, and the subsequent rebase would conflict with main's INDEX rows for decisions added on main since the branch diverged. + ```bash -bash .claude/skills/dld-common/scripts/regenerate-index.sh +bash .claude/skills/dld-common/scripts/regenerate-index.sh --include-base "$BASE" ``` ## Step 5: Ask about committing diff --git a/skills/dld-common/scripts/regenerate-index.sh b/skills/dld-common/scripts/regenerate-index.sh index 085705e..64bd2df 100755 --- a/skills/dld-common/scripts/regenerate-index.sh +++ b/skills/dld-common/scripts/regenerate-index.sh @@ -1,29 +1,60 @@ #!/usr/bin/env bash # Regenerate decisions/INDEX.md from all decision files. # Reads YAML frontmatter from each DL-*.md file and builds a markdown table. +# +# Optional --include-base : also include decision files from the given git +# ref (e.g. origin/main) that are not present in the working tree. Used by +# /dld-reindex so a pre-rebase INDEX.md contains both renamed-local rows and +# base-branch rows the local commit hasn't seen yet — the rebase then auto-merges. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" +INCLUDE_BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --include-base) INCLUDE_BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + DECISIONS_DIR="$(get_decisions_dir)" RECORDS_DIR="$(get_records_dir)" MODE="$(get_mode)" INDEX_FILE="$DECISIONS_DIR/INDEX.md" +PROJECT_ROOT="$(get_project_root)" +RECORDS_DIR_REL="${RECORDS_DIR#"$PROJECT_ROOT"/}" if [[ ! -d "$RECORDS_DIR" ]]; then echo "Error: records directory not found at $RECORDS_DIR" >&2 exit 1 fi -# Extract a frontmatter field from a decision file -# Usage: extract_field +if [[ -n "$INCLUDE_BASE" ]]; then + if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$INCLUDE_BASE^{commit}" >/dev/null; then + echo "Error: --include-base ref '$INCLUDE_BASE' not found." >&2 + exit 1 + fi +fi + +# Read the body of a "source spec" — either local: or base:. +read_source() { + local source="$1" + case "$source" in + base:*) git -C "$PROJECT_ROOT" show "$INCLUDE_BASE:${source#base:}" 2>/dev/null ;; + local:*) cat "${source#local:}" ;; + *) cat "$source" ;; # backward compat + esac +} + +# Extract a frontmatter field from a source spec. extract_field() { - local file="$1" + local source="$1" local field="$2" - # Read between --- markers, find the field - sed -n '/^---$/,/^---$/p' "$file" \ + read_source "$source" \ + | sed -n '/^---$/,/^---$/p' \ | grep "^${field}:" \ | head -1 \ | sed "s/^${field}:[[:space:]]*//" \ @@ -31,26 +62,43 @@ extract_field() { | sed "s/^'\(.*\)'$/\1/" } -# Extract array field as comma-separated string -# Usage: extract_array_field +# Extract array field as comma-separated string. extract_array_field() { - local file="$1" + local source="$1" local field="$2" local raw - raw=$(extract_field "$file" "$field") - # Handle YAML inline array: [tag1, tag2, tag3] + raw=$(extract_field "$source" "$field") echo "$raw" | sed 's/^\[//;s/\]$//;s/,[[:space:]]*/,/g;s/,/, /g' } -# Collect all decision files -# Sort by numeric ID descending: extract ID number, sort, reconstruct -DECISION_FILES=$(find "$RECORDS_DIR" -name 'DL-*.md' -type f \ - | awk -F/ '{file=$0; basename=$NF; gsub(/^DL-/,"",basename); gsub(/\.md$/,"",basename); print basename "\t" file}' \ - | sort -n -r \ - | cut -f2) +# Build the source list as \t lines. +# Local working-tree files first. +SOURCES="" +while IFS= read -r f; do + [[ -z "$f" ]] && continue + bn=$(basename "$f" .md) + num="${bn#DL-}" + SOURCES+="$num"$'\t'"local:$f"$'\n' +done < <(find "$RECORDS_DIR" -name 'DL-*.md' -type f 2>/dev/null) + +# Then base-only files (skip any whose basename already appears locally). +if [[ -n "$INCLUDE_BASE" ]]; then + LOCAL_BASENAMES=$(find "$RECORDS_DIR" -name 'DL-*.md' -type f -exec basename {} \; 2>/dev/null | sort -u) + while IFS= read -r p; do + [[ -z "$p" ]] && continue + bn=$(basename "$p") + if grep -qxF "$bn" <<<"$LOCAL_BASENAMES"; then + continue + fi + num=$(echo "$bn" | sed 's/^DL-//;s/\.md$//') + SOURCES+="$num"$'\t'"base:$p"$'\n' + done < <(git -C "$PROJECT_ROOT" ls-tree -r --name-only "$INCLUDE_BASE" -- "$RECORDS_DIR_REL" 2>/dev/null | grep -E 'DL-[0-9]+\.md$' || true) +fi + +# Strip the trailing newline and sort by numeric ID descending. +SORTED_SOURCES=$(printf '%s' "$SOURCES" | sort -t$'\t' -k1,1 -n -r | cut -f2) -if [[ -z "$DECISION_FILES" ]]; then - # Write empty index +if [[ -z "$SORTED_SOURCES" ]]; then { echo "# Decision Log" echo "" @@ -66,7 +114,6 @@ if [[ -z "$DECISION_FILES" ]]; then exit 0 fi -# Build the index { echo "# Decision Log" echo "" @@ -78,14 +125,15 @@ fi echo "|----|-------|--------|------|" fi - echo "$DECISION_FILES" | while IFS= read -r file; do - id=$(extract_field "$file" "id") - title=$(extract_field "$file" "title") - status=$(extract_field "$file" "status") - tags=$(extract_array_field "$file" "tags") + echo "$SORTED_SOURCES" | while IFS= read -r source; do + [[ -z "$source" ]] && continue + id=$(extract_field "$source" "id") + title=$(extract_field "$source" "title") + status=$(extract_field "$source" "status") + tags=$(extract_array_field "$source" "tags") if [[ "$MODE" == "namespaced" ]]; then - namespace=$(extract_field "$file" "namespace") + namespace=$(extract_field "$source" "namespace") echo "| $id | $title | $status | $namespace | $tags |" else echo "| $id | $title | $status | $tags |" diff --git a/skills/dld-reindex/SKILL.md b/skills/dld-reindex/SKILL.md index 787e340..8df6555 100644 --- a/skills/dld-reindex/SKILL.md +++ b/skills/dld-reindex/SKILL.md @@ -84,8 +84,10 @@ The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite ## Step 4: Regenerate INDEX.md +Pass `--include-base` so the regenerated INDEX merges in decisions that exist on the base branch but not yet locally. Without this flag, the regenerated INDEX would only contain rows the branch knows about, and the subsequent rebase would conflict with main's INDEX rows for decisions added on main since the branch diverged. + ```bash -bash ../dld-common/scripts/regenerate-index.sh +bash ../dld-common/scripts/regenerate-index.sh --include-base "$BASE" ``` ## Step 5: Ask about committing diff --git a/tests/test_regenerate_index.bats b/tests/test_regenerate_index.bats index 77e501d..de29139 100644 --- a/tests/test_regenerate_index.bats +++ b/tests/test_regenerate_index.bats @@ -83,6 +83,59 @@ teardown() { assert_output --partial "records directory not found" } +@test "regenerate-index --include-base merges base-only decisions" { + # Seed `main` with DL-001 and DL-002, then branch off. + create_decision "DL-001" "accepted" + create_decision "DL-002" "accepted" + git add -A + git commit --quiet -m "seed main" + git branch -M main + git checkout -b feature --quiet + + # Locally drop DL-002 and add DL-005 — simulates a renamed-away decision. + rm decisions/records/DL-002.md + create_decision "DL-005" "proposed" + git add -A + git commit --quiet -m "local DL-005" + + run bash "$SCRIPT" --include-base main + assert_success + + run cat decisions/INDEX.md + # Local DL-005 present + assert_output --partial "DL-005" + # Base-only DL-002 pulled in + assert_output --partial "DL-002" + # Shared DL-001 present + assert_output --partial "DL-001" +} + +@test "regenerate-index --include-base prefers local content over base" { + create_decision "DL-001" "accepted" + git add -A + git commit --quiet -m "seed main" + git branch -M main + git checkout -b feature --quiet + + # Edit DL-001 locally so we can detect which side wins. + sed -i.bak 's/^status: accepted$/status: superseded/' decisions/records/DL-001.md + rm decisions/records/DL-001.md.bak + git add -A + git commit --quiet -m "local edit" + + bash "$SCRIPT" --include-base main + run cat decisions/INDEX.md + assert_output --partial "superseded" + refute_output --partial "| DL-001 | Test decision DL-001 | accepted |" +} + +@test "regenerate-index --include-base rejects unknown ref" { + create_decision "DL-001" "accepted" + run bash "$SCRIPT" --include-base does/not/exist + assert_failure + assert_output --partial "not found" +} + @test "regenerate-index overwrites existing INDEX.md" { echo "old content" > decisions/INDEX.md create_decision "DL-001" "accepted" From 2086ac3c27bbe43234d53b0f9a4dfd294855b0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 14:11:14 +0200 Subject: [PATCH 4/8] Collapse /dld-reindex planning into plan-renames.sh; ban /tmp scratch files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plan-renames.sh which folds collision detection, taken-ID scan, and free-ID assignment into one deterministic output. The SKILL now calls that single script instead of having the agent stitch together three helpers and recompute the max — which is what was driving the /tmp/taken-ids.txt and /tmp/stderr.txt detours. Also adds an explicit "do not redirect to /tmp" guard in the SKILL prelude. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/dld-reindex/SKILL.md | 34 +++++----- .../dld-reindex/scripts/plan-renames.sh | 67 +++++++++++++++++++ skills/dld-reindex/SKILL.md | 34 +++++----- skills/dld-reindex/scripts/plan-renames.sh | 67 +++++++++++++++++++ tests/test_reindex.bats | 52 ++++++++++++++ 5 files changed, 222 insertions(+), 32 deletions(-) create mode 100755 .claude/skills/dld-reindex/scripts/plan-renames.sh create mode 100755 skills/dld-reindex/scripts/plan-renames.sh diff --git a/.claude/skills/dld-reindex/SKILL.md b/.claude/skills/dld-reindex/SKILL.md index 3e69fce..a2490c5 100644 --- a/.claude/skills/dld-reindex/SKILL.md +++ b/.claude/skills/dld-reindex/SKILL.md @@ -14,6 +14,8 @@ You are helping the developer untangle decision ID collisions before they rebase Use the `AskUserQuestion` tool when prompting for the commit decision at the end. Everything else is deterministic and runs without user input. +**Do not redirect any command output to `/tmp` files.** The scripts in this skill emit only what you need to act on; piping to `/tmp/*.txt`, `tee`-ing into scratch files, or stashing stderr separately is unnecessary and creates clutter outside the repo. If a command's output is too long to read in one go, narrow it (`| tail -N`, `| head -N`, or pass a more specific flag) rather than persisting it. + ## Script Paths Shared scripts: @@ -24,6 +26,7 @@ Shared scripts: Skill-specific scripts: ``` +.claude/skills/dld-reindex/scripts/plan-renames.sh .claude/skills/dld-reindex/scripts/find-collisions.sh .claude/skills/dld-reindex/scripts/list-taken-ids.sh .claude/skills/dld-reindex/scripts/rename-decision.sh @@ -43,30 +46,29 @@ Skill-specific scripts: git fetch origin ``` -## Step 1: Detect collisions +## Step 1: Plan the renames ```bash -bash .claude/skills/dld-reindex/scripts/find-collisions.sh --base "$BASE" +bash .claude/skills/dld-reindex/scripts/plan-renames.sh --base "$BASE" ``` -The output is one line per collision: `\t`. If there is no output, exit with: - -> No ID collisions detected. Safe to rebase onto `$BASE`. +This combines collision detection, the `gh`-aware "taken IDs" scan, and free-ID assignment into a single deterministic plan. Output is tab-separated, one rename per line: -The same script's underlying `list-taken-ids.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. +``` +\t\t +``` -## Step 2: Compute the next free IDs +If the output is empty, exit with: -Collect the inputs: +> No ID collisions detected. Safe to rebase onto `$BASE`. -- `TAKEN` — output of `bash .claude/skills/dld-reindex/scripts/list-taken-ids.sh --base "$BASE"`. IDs already used on the base branch (and on open PRs when `gh` is available). -- `LOCAL_KEPT` — local-added decision IDs that are NOT in the collision list. These are IDs the user is keeping; they must not be reassigned to anything else. +`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed` (from the underlying `list-taken-ids.sh`). **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. To read stderr alongside stdout in a single Bash call, append `2>&1` — do not split the output into separate `/tmp` files. -Compute the highest numeric ID across `TAKEN ∪ LOCAL_KEPT`. Assign the next sequential IDs (`max + 1`, `max + 2`, …) to the colliding decisions **in numeric order**, padded with `printf "DL-%03d"`. +The underlying helpers (`find-collisions.sh`, `list-taken-ids.sh`) remain available for debugging, but the SKILL flow always goes through `plan-renames.sh`. -## Step 3: Apply renames +## Step 2: Apply renames -For each colliding decision (in order), call: +For each line in the plan output, call: ```bash bash .claude/skills/dld-reindex/scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path --base "$BASE" @@ -82,7 +84,7 @@ bash .claude/skills/dld-reindex/scripts/rename-decision.sh --old DL-OLD --new DL The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. -## Step 4: Regenerate INDEX.md +## Step 3: Regenerate INDEX.md Pass `--include-base` so the regenerated INDEX merges in decisions that exist on the base branch but not yet locally. Without this flag, the regenerated INDEX would only contain rows the branch knows about, and the subsequent rebase would conflict with main's INDEX rows for decisions added on main since the branch diverged. @@ -90,7 +92,7 @@ Pass `--include-base` so the regenerated INDEX merges in decisions that exist on bash .claude/skills/dld-common/scripts/regenerate-index.sh --include-base "$BASE" ``` -## Step 5: Ask about committing +## Step 4: Ask about committing Present the rename table to the user (old ID → new ID, one row per rename) and ask via `AskUserQuestion`: @@ -110,7 +112,7 @@ reindex local decisions: DL-OLD1 -> DL-NEW1, DL-OLD2 -> DL-NEW2 For more than three renames, summarize as `reindex N local decisions to avoid base-branch collisions` and put the full table in the body. -## Step 6: Report +## Step 5: Report Print: diff --git a/.claude/skills/dld-reindex/scripts/plan-renames.sh b/.claude/skills/dld-reindex/scripts/plan-renames.sh new file mode 100755 index 0000000..cfec878 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/plan-renames.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Plan all renames needed to resolve ID collisions in one shot. +# Combines find-collisions.sh and list-taken-ids.sh, computes the next free IDs, +# and outputs a deterministic rename plan. +# +# Output (stdout): one line per rename, tab-separated: +# \t\t +# Output is empty when there are no collisions. +# Stderr passes through the gh-skip notice (if any) from list-taken-ids.sh. +# +# Usage: plan-renames.sh [--base ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="origin/main" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +PROJECT_ROOT="$(get_project_root)" +RECORDS_DIR_REL="$(config_get decisions_dir)/records" + +COLLISIONS=$(bash "$SCRIPT_DIR/find-collisions.sh" --base "$BASE") +if [[ -z "$COLLISIONS" ]]; then + exit 0 +fi + +# Full set of IDs the renamed decisions must avoid: anything taken on the base +# branch / in open PRs, plus every locally-added ID (kept or colliding — the +# colliding ones don't matter for the max calculation but listing them keeps +# the union simple). +TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE") +LOCAL_ADDED=$( + git -C "$PROJECT_ROOT" diff --name-only --diff-filter=A "$BASE"...HEAD -- "$RECORDS_DIR_REL" 2>/dev/null \ + | xargs -I{} basename {} .md \ + | grep -E '^DL-[0-9]+$' \ + || true +) + +HIGHEST=$( + { + [[ -n "$TAKEN" ]] && echo "$TAKEN" + [[ -n "$LOCAL_ADDED" ]] && echo "$LOCAL_ADDED" + } \ + | grep -oE '^DL-[0-9]+$' \ + | sed 's/^DL-//' \ + | sort -n \ + | tail -1 +) +HIGHEST="${HIGHEST:-0}" + +# Sort colliding entries by numeric ID so the plan is deterministic. +COLLISIONS_SORTED=$(echo "$COLLISIONS" | sort -t$'\t' -k2,2) + +next=$((10#$HIGHEST + 1)) +while IFS=$'\t' read -r path old_id; do + [[ -z "$path" ]] && continue + new_id=$(printf "DL-%03d" "$next") + printf "%s\t%s\t%s\n" "$path" "$old_id" "$new_id" + next=$((next + 1)) +done <<< "$COLLISIONS_SORTED" diff --git a/skills/dld-reindex/SKILL.md b/skills/dld-reindex/SKILL.md index 8df6555..78fd4c5 100644 --- a/skills/dld-reindex/SKILL.md +++ b/skills/dld-reindex/SKILL.md @@ -14,6 +14,8 @@ You are helping the developer untangle decision ID collisions before they rebase Use the `AskUserQuestion` tool when prompting for the commit decision at the end. Everything else is deterministic and runs without user input. +**Do not redirect any command output to `/tmp` files.** The scripts in this skill emit only what you need to act on; piping to `/tmp/*.txt`, `tee`-ing into scratch files, or stashing stderr separately is unnecessary and creates clutter outside the repo. If a command's output is too long to read in one go, narrow it (`| tail -N`, `| head -N`, or pass a more specific flag) rather than persisting it. + ## Script Paths Shared scripts: @@ -24,6 +26,7 @@ Shared scripts: Skill-specific scripts: ``` +scripts/plan-renames.sh scripts/find-collisions.sh scripts/list-taken-ids.sh scripts/rename-decision.sh @@ -43,30 +46,29 @@ scripts/rename-decision.sh git fetch origin ``` -## Step 1: Detect collisions +## Step 1: Plan the renames ```bash -bash scripts/find-collisions.sh --base "$BASE" +bash scripts/plan-renames.sh --base "$BASE" ``` -The output is one line per collision: `\t`. If there is no output, exit with: - -> No ID collisions detected. Safe to rebase onto `$BASE`. +This combines collision detection, the `gh`-aware "taken IDs" scan, and free-ID assignment into a single deterministic plan. Output is tab-separated, one rename per line: -The same script's underlying `list-taken-ids.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. +``` +\t\t +``` -## Step 2: Compute the next free IDs +If the output is empty, exit with: -Collect the inputs: +> No ID collisions detected. Safe to rebase onto `$BASE`. -- `TAKEN` — output of `bash scripts/list-taken-ids.sh --base "$BASE"`. IDs already used on the base branch (and on open PRs when `gh` is available). -- `LOCAL_KEPT` — local-added decision IDs that are NOT in the collision list. These are IDs the user is keeping; they must not be reassigned to anything else. +`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed` (from the underlying `list-taken-ids.sh`). **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. To read stderr alongside stdout in a single Bash call, append `2>&1` — do not split the output into separate `/tmp` files. -Compute the highest numeric ID across `TAKEN ∪ LOCAL_KEPT`. Assign the next sequential IDs (`max + 1`, `max + 2`, …) to the colliding decisions **in numeric order**, padded with `printf "DL-%03d"`. +The underlying helpers (`find-collisions.sh`, `list-taken-ids.sh`) remain available for debugging, but the SKILL flow always goes through `plan-renames.sh`. -## Step 3: Apply renames +## Step 2: Apply renames -For each colliding decision (in order), call: +For each line in the plan output, call: ```bash bash scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path --base "$BASE" @@ -82,7 +84,7 @@ bash scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. -## Step 4: Regenerate INDEX.md +## Step 3: Regenerate INDEX.md Pass `--include-base` so the regenerated INDEX merges in decisions that exist on the base branch but not yet locally. Without this flag, the regenerated INDEX would only contain rows the branch knows about, and the subsequent rebase would conflict with main's INDEX rows for decisions added on main since the branch diverged. @@ -90,7 +92,7 @@ Pass `--include-base` so the regenerated INDEX merges in decisions that exist on bash ../dld-common/scripts/regenerate-index.sh --include-base "$BASE" ``` -## Step 5: Ask about committing +## Step 4: Ask about committing Present the rename table to the user (old ID → new ID, one row per rename) and ask via `AskUserQuestion`: @@ -110,7 +112,7 @@ reindex local decisions: DL-OLD1 -> DL-NEW1, DL-OLD2 -> DL-NEW2 For more than three renames, summarize as `reindex N local decisions to avoid base-branch collisions` and put the full table in the body. -## Step 6: Report +## Step 5: Report Print: diff --git a/skills/dld-reindex/scripts/plan-renames.sh b/skills/dld-reindex/scripts/plan-renames.sh new file mode 100755 index 0000000..cfec878 --- /dev/null +++ b/skills/dld-reindex/scripts/plan-renames.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Plan all renames needed to resolve ID collisions in one shot. +# Combines find-collisions.sh and list-taken-ids.sh, computes the next free IDs, +# and outputs a deterministic rename plan. +# +# Output (stdout): one line per rename, tab-separated: +# \t\t +# Output is empty when there are no collisions. +# Stderr passes through the gh-skip notice (if any) from list-taken-ids.sh. +# +# Usage: plan-renames.sh [--base ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="origin/main" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +PROJECT_ROOT="$(get_project_root)" +RECORDS_DIR_REL="$(config_get decisions_dir)/records" + +COLLISIONS=$(bash "$SCRIPT_DIR/find-collisions.sh" --base "$BASE") +if [[ -z "$COLLISIONS" ]]; then + exit 0 +fi + +# Full set of IDs the renamed decisions must avoid: anything taken on the base +# branch / in open PRs, plus every locally-added ID (kept or colliding — the +# colliding ones don't matter for the max calculation but listing them keeps +# the union simple). +TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE") +LOCAL_ADDED=$( + git -C "$PROJECT_ROOT" diff --name-only --diff-filter=A "$BASE"...HEAD -- "$RECORDS_DIR_REL" 2>/dev/null \ + | xargs -I{} basename {} .md \ + | grep -E '^DL-[0-9]+$' \ + || true +) + +HIGHEST=$( + { + [[ -n "$TAKEN" ]] && echo "$TAKEN" + [[ -n "$LOCAL_ADDED" ]] && echo "$LOCAL_ADDED" + } \ + | grep -oE '^DL-[0-9]+$' \ + | sed 's/^DL-//' \ + | sort -n \ + | tail -1 +) +HIGHEST="${HIGHEST:-0}" + +# Sort colliding entries by numeric ID so the plan is deterministic. +COLLISIONS_SORTED=$(echo "$COLLISIONS" | sort -t$'\t' -k2,2) + +next=$((10#$HIGHEST + 1)) +while IFS=$'\t' read -r path old_id; do + [[ -z "$path" ]] && continue + new_id=$(printf "DL-%03d" "$next") + printf "%s\t%s\t%s\n" "$path" "$old_id" "$new_id" + next=$((next + 1)) +done <<< "$COLLISIONS_SORTED" diff --git a/tests/test_reindex.bats b/tests/test_reindex.bats index 37396be..6e58ea8 100644 --- a/tests/test_reindex.bats +++ b/tests/test_reindex.bats @@ -88,6 +88,58 @@ teardown() { assert_output --partial "DL-001" } +# --- plan-renames.sh --------------------------------------------------------- + +@test "plan-renames: empty output when there are no collisions" { + create_decision "DL-005" "proposed" + git add -A + git commit --quiet -m "local DL-005" + + run bash -c "bash \"$REINDEX_DIR/plan-renames.sh\" --base main 2>/dev/null" + assert_success + assert_output "" +} + +@test "plan-renames: assigns the next free ID above max(taken ∪ local_added)" { + # main lands DL-007 after the branch point. + git checkout main --quiet + create_decision "DL-007" "accepted" + git add -A + git commit --quiet -m "land DL-007 on main" + git checkout feature --quiet + + # Local adds DL-007 (collides with main) and DL-009 (kept). + create_decision "DL-007" "proposed" + create_decision "DL-009" "proposed" + git add -A + git commit --quiet -m "local DL-007 and DL-009" + + run bash -c "bash \"$REINDEX_DIR/plan-renames.sh\" --base main 2>/dev/null" + assert_success + # max(taken ∪ local_added) = max({DL-001, DL-007} ∪ {DL-007, DL-009}) = 9 → next free = DL-010 + assert_output --partial $'decisions/records/DL-007.md\tDL-007\tDL-010' +} + +@test "plan-renames: multiple collisions get sequential free IDs" { + git checkout main --quiet + create_decision "DL-002" "accepted" + create_decision "DL-003" "accepted" + git add -A + git commit --quiet -m "land DL-002 and DL-003 on main" + git checkout feature --quiet + + create_decision "DL-002" "proposed" + create_decision "DL-003" "proposed" + git add -A + git commit --quiet -m "local DL-002 and DL-003" + + run bash -c "bash \"$REINDEX_DIR/plan-renames.sh\" --base main 2>/dev/null" + assert_success + # max(taken) = 3, max(local_added) = 3 → next free = DL-004, DL-005 + assert_output --partial $'decisions/records/DL-002.md\tDL-002\tDL-004' + assert_output --partial $'decisions/records/DL-003.md\tDL-003\tDL-005' +} + # --- rename-decision.sh ------------------------------------------------------ @test "rename-decision: git mv preserves rename history" { From c48d64d43f349a5bbf1f7ec5752d4c74aec41470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 14:45:39 +0200 Subject: [PATCH 5/8] Rewrite branch history in /dld-reindex; explicit-path commits; smarter base The previous flow renamed colliding decisions in a final commit on top of the branch, but `git rebase` replays commits one at a time and chokes on the original add commit's colliding path (e.g. decisions/records/DL-205.md) before it ever sees the rename. The only fix is to ensure the colliding path never appears in the branch's history. This change replaces step 6 of the SKILL with a squash flow: the renames are applied to the working tree, then commit-reindex.sh mixed-resets HEAD to the merge-base and creates one new reindex commit. The original branch commits' subjects are preserved in the new commit body. An explicit AskUserQuestion gate makes the history rewrite consensual. INDEX.md is intentionally excluded from the reindex commit. Including it caused content conflicts during rebase whenever the base also modified INDEX.md (git's 3-way merge can't align both sides' top-of-file inserts even when row content overlaps). commit-reindex.sh restores INDEX.md to merge-base state in the working tree so the user has no uncommitted changes after the flow; the post-rebase step is a one-line regenerate. Also: - commit-reindex.sh stages an EXPLICIT path list (the rename plan's old/new paths plus every file touched by the original branch). `git add -A` is no longer used anywhere in the flow, so untracked unrelated paths (e.g. .claude/worktrees, scratch files) can never be swept in. - resolve-base.sh resolves the base ref via @{upstream}, but falls back to origin/main when the upstream tracks a branch with the same name as the current branch (i.e. it's just the remote copy of this same branch, not a useful collision base). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/dld-reindex/SKILL.md | 110 ++++++---- .../dld-reindex/scripts/commit-reindex.sh | 160 ++++++++++++++ .../dld-reindex/scripts/resolve-base.sh | 26 +++ skills/dld-reindex/SKILL.md | 110 ++++++---- skills/dld-reindex/scripts/commit-reindex.sh | 160 ++++++++++++++ skills/dld-reindex/scripts/resolve-base.sh | 26 +++ tests/test_reindex.bats | 197 ++++++++++++++++++ 7 files changed, 709 insertions(+), 80 deletions(-) create mode 100755 .claude/skills/dld-reindex/scripts/commit-reindex.sh create mode 100755 .claude/skills/dld-reindex/scripts/resolve-base.sh create mode 100755 skills/dld-reindex/scripts/commit-reindex.sh create mode 100755 skills/dld-reindex/scripts/resolve-base.sh diff --git a/.claude/skills/dld-reindex/SKILL.md b/.claude/skills/dld-reindex/SKILL.md index a2490c5..9b87800 100644 --- a/.claude/skills/dld-reindex/SKILL.md +++ b/.claude/skills/dld-reindex/SKILL.md @@ -1,18 +1,20 @@ --- name: dld-reindex -description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, regenerates INDEX.md, and optionally commits. +description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, then squashes branch commits into a single rebase-clean reindex commit. user_invocable: true --- # /dld-reindex — Resolve Decision ID Collisions -You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically so you don't lose history, annotations, or cross-references. +You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically and produces a branch state that rebases cleanly. -**This skill must run before rebasing.** Do not bring remote changes in first — that turns a clean rename into a merge conflict. +**This skill rewrites branch history.** That's not optional: if a colliding path (e.g. `decisions/records/DL-205.md`) was added by any commit on the branch, `git rebase` will hit an add/add conflict on that commit *before* it ever sees a later rename. The only fix is to ensure the colliding path never appears in the branch's history. The skill does this by squashing all branch commits since the merge-base into one reindex commit containing the renamed files. + +If the branch has already been pushed, finishing the reindex will require a `--force-with-lease` push. The skill asks for explicit consent before rewriting history. ## Interaction style -Use the `AskUserQuestion` tool when prompting for the commit decision at the end. Everything else is deterministic and runs without user input. +Use the `AskUserQuestion` tool when prompting for consent and at the finish step. Everything else is deterministic. **Do not redirect any command output to `/tmp` files.** The scripts in this skill emit only what you need to act on; piping to `/tmp/*.txt`, `tee`-ing into scratch files, or stashing stderr separately is unnecessary and creates clutter outside the repo. If a command's output is too long to read in one go, narrow it (`| tail -N`, `| head -N`, or pass a more specific flag) rather than persisting it. @@ -26,33 +28,40 @@ Shared scripts: Skill-specific scripts: ``` +.claude/skills/dld-reindex/scripts/resolve-base.sh .claude/skills/dld-reindex/scripts/plan-renames.sh .claude/skills/dld-reindex/scripts/find-collisions.sh .claude/skills/dld-reindex/scripts/list-taken-ids.sh .claude/skills/dld-reindex/scripts/rename-decision.sh +.claude/skills/dld-reindex/scripts/commit-reindex.sh ``` ## Prerequisites 1. Check that `dld.config.yaml` exists at the repo root. If not, tell the user to run `/dld-init` first and stop. 2. Verify the working tree is clean (`git status --porcelain` empty). If not, ask the user to commit or stash first, then stop. -3. Determine the base branch. Prefer the upstream of the current branch: - ```bash - git rev-parse --abbrev-ref --symbolic-full-name @{upstream} - ``` - If there is no upstream, default to `origin/main`. The user can override by passing a base ref to this skill (e.g. `/dld-reindex origin/develop`). -4. Fetch the base ref so collision detection sees the latest state: +3. Fetch the latest base state: ```bash git fetch origin ``` -## Step 1: Plan the renames +## Step 1: Resolve the base ref + +```bash +BASE=$(bash .claude/skills/dld-reindex/scripts/resolve-base.sh) +``` + +`resolve-base.sh` prefers the branch's upstream when it tracks a *different* branch (the typical "feature → main" setup). It falls back to `origin/main` if the upstream is unset OR if the upstream tracks the same branch name as the current branch (i.e. it's just the remote copy of this same branch, not a useful collision base). + +The user may pass an explicit base when invoking the skill (e.g. `/dld-reindex origin/develop`) — honor it if present. + +## Step 2: Plan the renames ```bash bash .claude/skills/dld-reindex/scripts/plan-renames.sh --base "$BASE" ``` -This combines collision detection, the `gh`-aware "taken IDs" scan, and free-ID assignment into a single deterministic plan. Output is tab-separated, one rename per line: +Output is tab-separated, one rename per line: ``` \t\t @@ -62,69 +71,90 @@ If the output is empty, exit with: > No ID collisions detected. Safe to rebase onto `$BASE`. -`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed` (from the underlying `list-taken-ids.sh`). **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. To read stderr alongside stdout in a single Bash call, append `2>&1` — do not split the output into separate `/tmp` files. +`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. The underlying helpers (`find-collisions.sh`, `list-taken-ids.sh`) remain available for debugging, but the SKILL flow always goes through `plan-renames.sh`. -## Step 2: Apply renames +## Step 3: Get explicit consent for the history rewrite -For each line in the plan output, call: +Show the user the rename plan and the implication. Use `AskUserQuestion`: + +> Resolving these collisions requires rewriting branch history. I will squash the N commits since `` into a single reindex commit. The original commit subjects will be preserved in the new commit body. If the branch has already been pushed, finishing will require `git push --force-with-lease`. How should I proceed? + +Options: +- **Rewrite and force-push** — agent applies renames, squashes, commits, and runs `git push --force-with-lease`. +- **Rewrite only** — agent applies renames, squashes, and commits. User pushes when ready. +- **Cancel** — abort with no changes. + +If the user cancels, exit without touching anything. + +## Step 4: Apply renames + +For each line in the plan, call: ```bash bash .claude/skills/dld-reindex/scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path --base "$BASE" ``` -`rename-decision.sh` does all of the following in one call: +`rename-decision.sh` does all of: -- `git mv` the file from `DL-OLD.md` to `DL-NEW.md` (preserves rename history). +- `git mv` the file from `DL-OLD.md` to `DL-NEW.md`. - Patches the `id:` frontmatter field in the renamed file. -- Rewrites `DL-OLD` mentions inside the renamed file's body (so internal cross-references survive the rename). -- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). It scopes this to the local change set vs the base ref, so it never touches decisions that already existed on the base. +- Rewrites `DL-OLD` mentions inside the renamed file's body. +- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). Scoped to the local change set vs the base ref. - Rewrites `` `@decision` ``(DL-OLD) annotations to `` `@decision` ``(DL-NEW) in non-decision files that are part of the local change set. The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. -## Step 3: Regenerate INDEX.md +## Step 5: Squash and commit -Pass `--include-base` so the regenerated INDEX merges in decisions that exist on the base branch but not yet locally. Without this flag, the regenerated INDEX would only contain rows the branch knows about, and the subsequent rebase would conflict with main's INDEX rows for decisions added on main since the branch diverged. +Pipe the rename plan into `commit-reindex.sh`: ```bash -bash .claude/skills/dld-common/scripts/regenerate-index.sh --include-base "$BASE" +echo "$PLAN" | bash .claude/skills/dld-reindex/scripts/commit-reindex.sh --base "$BASE" ``` -## Step 4: Ask about committing - -Present the rename table to the user (old ID → new ID, one row per rename) and ask via `AskUserQuestion`: +This: -> How should I finish this reindex? +1. Computes the merge-base with `$BASE`. +2. Mixed-resets HEAD to the merge-base (working tree is preserved — it already holds the post-rename state from step 4). +3. Restores `INDEX.md` in the working tree to its merge-base state so the working tree is consistent with what we're about to commit. **INDEX.md is intentionally excluded from the reindex commit** — including it would cause a content conflict during rebase whenever the base branch also modified INDEX.md (git's 3-way merge fails to align both sides' top-of-file inserts even when row content overlaps). INDEX.md gets regenerated post-rebase instead. +4. Stages **only** an explicit path list derived from the original branch diff and the rename plan — the old paths (for deletions), the new paths (for additions), every other file the branch touched. Untracked unrelated paths (e.g. `.claude/worktrees`, scratch files, in-progress edits to unrelated files) are deliberately NOT swept in. +5. Commits with a templated message that lists the renames in the subject and preserves the original branch commits' subjects in the body. -Options (single-select): +**Do not use `git add -A` or `git commit -a` anywhere in this flow.** Use only `commit-reindex.sh` to commit. Targeting paths explicitly is the whole point of this step. -- **Commit and push** — agent runs `git add -A && git commit && git push`. -- **Commit only** — agent runs `git add -A && git commit`, leaves the push to the user. -- **Leave for me** — agent stages nothing further; the working-tree state stays as rename-decision.sh left it (the `git mv`'s already register the renames in the index). +## Step 6: Push (if the user chose force-push) -For "Commit and push" / "Commit only", use this commit message format: +If step 3's answer was "Rewrite and force-push": -``` -reindex local decisions: DL-OLD1 -> DL-NEW1, DL-OLD2 -> DL-NEW2 +```bash +if git rev-parse --verify --quiet "@{upstream}" >/dev/null; then + git push --force-with-lease +else + git push -u origin HEAD +fi ``` -For more than three renames, summarize as `reindex N local decisions to avoid base-branch collisions` and put the full table in the body. +`--force-with-lease` is mandatory over `--force` here — it refuses to push if the remote moved since the last fetch, which protects against overwriting concurrent collaborator pushes. -## Step 5: Report +## Step 7: Report Print: -- The renames table (always). -- The stderr note from step 1 if `gh` was skipped (always — even if the user chose to commit). -- The next-step hint: +- The renames table. +- The stderr note from step 2 if `gh` was skipped. +- The number of commits squashed. +- The next steps: -> Next step: `git rebase $BASE` +> 1. `git rebase $BASE` +> 2. `bash .claude/skills/dld-common/scripts/regenerate-index.sh` (to repopulate INDEX.md with the renamed locals — the reindex commit intentionally leaves INDEX.md alone to keep the rebase conflict-free; INDEX.md is missing the renamed rows until you regenerate) +> 3. Commit the INDEX.md update The skill never rebases or merges — that is always the user's call. ## Out of scope -- **Already-conflicted rebases.** This skill is pre-rebase only. If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. +- **Already-conflicted rebases.** If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. +- **Preserving per-commit granularity.** The squash trades original commit boundaries for a deterministic rewrite. A future `--preserve-history` flag could perform a cherry-pick walk that rewrites each commit individually, but the edge cases (commits modifying an already-renamed file, merge commits, partial reruns) make it materially more complex than the squash. - **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`. diff --git a/.claude/skills/dld-reindex/scripts/commit-reindex.sh b/.claude/skills/dld-reindex/scripts/commit-reindex.sh new file mode 100755 index 0000000..252700e --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/commit-reindex.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Squash the branch's commits since the merge-base with $BASE into a single +# reindex commit. Required because if the original branch added decision files +# at colliding paths (e.g. DL-205.md), those paths exist in the branch's HISTORY +# even after a later rename — and `git rebase` will hit an add/add conflict on +# the original add commit. Rewriting history so the colliding paths never +# appear in any branch commit is the only reliable fix. +# +# Stages an EXPLICIT path list derived from the original branch diff (mapped +# through the rename plan). Untracked unrelated paths (.claude/worktrees, +# scratch dirs, in-progress unrelated edits) are never swept in — `git add -A` +# with no pathspec is deliberately avoided. +# +# INDEX.md is INTENTIONALLY excluded from the commit and restored in the +# working tree to its merge-base state. Including it would cause a content +# conflict during rebase whenever the base branch also modified INDEX.md, since +# both sides insert rows at the top of the same file (git's 3-way merge fails +# to align them even when row content overlaps). The post-rebase step in the +# SKILL is to regenerate INDEX.md once, which is conflict-free. +# +# Reads the rename plan from stdin, one rename per line (tab-separated): +# \t\t +# +# Usage: bash commit-reindex.sh --base + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$BASE" ]]; then + echo "Error: --base is required." >&2 + exit 1 +fi + +PROJECT_ROOT="$(get_project_root)" +cd "$PROJECT_ROOT" + +if ! git rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "Error: base ref '$BASE' not found." >&2 + exit 1 +fi + +PLAN=$(cat) +if [[ -z "$PLAN" ]]; then + echo "Error: no rename plan on stdin." >&2 + exit 1 +fi + +MERGE_BASE=$(git merge-base "$BASE" HEAD) + +if [[ "$MERGE_BASE" == "$(git rev-parse HEAD)" ]]; then + echo "Error: HEAD is already at the merge-base — nothing to squash." >&2 + exit 1 +fi + +# Files touched by branch commits since merge-base (pre-rename perspective). +BRANCH_FILES=$(git diff --name-only --diff-filter=AMRD "$MERGE_BASE"..HEAD) + +# Build the explicit stage set. We collect every relevant path so we can +# `git add -A --` each one (which handles add/modify/delete uniformly). +DECISIONS_DIR_REL="$(config_get decisions_dir)" +PATHS=() + +# Renamed files: stage BOTH old and new paths so that deletions of the old +# (if it ever existed in merge-base) and additions of the new are captured. +while IFS=$'\t' read -r old_path old_id new_id; do + [[ -z "$old_path" ]] && continue + new_path="$(dirname "$old_path")/$new_id.md" + PATHS+=("$old_path" "$new_path") +done <<< "$PLAN" + +INDEX_PATH="$DECISIONS_DIR_REL/INDEX.md" + +# Other branch-touched files (annotation rewrites land here). +# INDEX.md is deliberately excluded — see the header comment. +while IFS= read -r f; do + [[ -z "$f" ]] && continue + [[ "$f" == "$INDEX_PATH" ]] && continue + PATHS+=("$f") +done <<< "$BRANCH_FILES" + +# Capture original commit subjects (for the new commit body) BEFORE we move HEAD. +ORIGINAL_COMMITS=$(git log --reverse --format='- %s' "$MERGE_BASE"..HEAD) + +# Build the rename summary for the subject line. +RENAME_COUNT=0 +RENAMES="" +RENAME_LIST="" +while IFS=$'\t' read -r _ old_id new_id; do + [[ -z "$old_id" ]] && continue + RENAME_COUNT=$((RENAME_COUNT + 1)) + if [[ -n "$RENAMES" ]]; then + RENAMES+=", " + fi + RENAMES+="${old_id} -> ${new_id}" + RENAME_LIST+="- ${old_id} -> ${new_id}"$'\n' +done <<< "$PLAN" + +if [[ "$RENAME_COUNT" -gt 3 ]]; then + SUBJECT="reindex $RENAME_COUNT local decisions to avoid base-branch collisions" +else + SUBJECT="reindex local decisions: $RENAMES" +fi + +# Mixed reset to merge-base — moves HEAD, clears the index, leaves the working tree alone. +git reset --quiet "$MERGE_BASE" + +# Restore INDEX.md in the working tree to match merge-base state (or remove +# it entirely if merge-base didn't have it). This keeps the working tree +# consistent with what we're about to commit (which excludes INDEX.md). +if git cat-file -e "HEAD:$INDEX_PATH" 2>/dev/null; then + git checkout HEAD -- "$INDEX_PATH" +elif [[ -f "$INDEX_PATH" ]]; then + rm -f "$INDEX_PATH" +fi + +# Dedup the path list and stage each explicitly. +printf '%s\n' "${PATHS[@]}" | sort -u | while IFS= read -r p; do + [[ -z "$p" ]] && continue + # `git add -A --` on a single pathspec stages add/modify/delete for THAT path only. + # If the path doesn't exist on disk and isn't in the index, git is a no-op + non-zero; + # swallow that case rather than abort. + git add -A -- "$p" 2>/dev/null || true +done + +if git diff --cached --quiet; then + echo "Error: nothing to commit after squash. The reindex may have already been applied, or the plan didn't match the branch state." >&2 + exit 1 +fi + +# Build the full message. +if [[ -n "$ORIGINAL_COMMITS" ]]; then + FULL_MSG="$SUBJECT + +Renames: +${RENAME_LIST% +} + +Squashed from original branch commits: +$ORIGINAL_COMMITS" +else + FULL_MSG="$SUBJECT + +Renames: +${RENAME_LIST% +}" +fi + +git commit --quiet -m "$FULL_MSG" +NEW_HEAD=$(git rev-parse --short HEAD) +echo "Created reindex commit $NEW_HEAD on top of $(git rev-parse --short "$MERGE_BASE")" diff --git a/.claude/skills/dld-reindex/scripts/resolve-base.sh b/.claude/skills/dld-reindex/scripts/resolve-base.sh new file mode 100755 index 0000000..426efa4 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/resolve-base.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Resolve the base ref for /dld-reindex. +# +# Prefers the branch's upstream when it tracks a DIFFERENT branch name +# (the typical "feature → main" setup). Falls back to origin/main when: +# * No upstream is configured. +# * Upstream tracks a branch with the same name as the current branch +# (e.g. current=test/dld-reindex-dummy, upstream=origin/test/dld-reindex-dummy +# — that's the remote copy of the same branch, not a useful collision base). +# +# Outputs the resolved ref. Exits non-zero only if it can't resolve anything. + +set -euo pipefail + +CURRENT=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + +UPSTREAM="" +if UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "@{upstream}" 2>/dev/null); then + UPSTREAM_BRANCH="${UPSTREAM#*/}" + if [[ -n "$UPSTREAM_BRANCH" && "$UPSTREAM_BRANCH" != "$CURRENT" ]]; then + echo "$UPSTREAM" + exit 0 + fi +fi + +echo "origin/main" diff --git a/skills/dld-reindex/SKILL.md b/skills/dld-reindex/SKILL.md index 78fd4c5..c98f229 100644 --- a/skills/dld-reindex/SKILL.md +++ b/skills/dld-reindex/SKILL.md @@ -1,18 +1,20 @@ --- name: dld-reindex -description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, regenerates INDEX.md, and optionally commits. +description: Resolve decision ID collisions between a local branch and the base branch (and open PRs) before rebasing. Renames colliding local decisions with git mv, rewrites cross-references and annotations, then squashes branch commits into a single rebase-clean reindex commit. compatibility: Requires bash and git. Open-PR scanning additionally needs the `gh` CLI authenticated against a GitHub remote — the skill falls back gracefully when unavailable. --- # /dld-reindex — Resolve Decision ID Collisions -You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically so you don't lose history, annotations, or cross-references. +You are helping the developer untangle decision ID collisions before they rebase. Two or more developers can draft `DL-NNN` decisions in parallel; once one of them lands on the base branch (or appears in an open PR), the others must rename their local copies to the next free ID. This skill handles that mechanically and produces a branch state that rebases cleanly. -**This skill must run before rebasing.** Do not bring remote changes in first — that turns a clean rename into a merge conflict. +**This skill rewrites branch history.** That's not optional: if a colliding path (e.g. `decisions/records/DL-205.md`) was added by any commit on the branch, `git rebase` will hit an add/add conflict on that commit *before* it ever sees a later rename. The only fix is to ensure the colliding path never appears in the branch's history. The skill does this by squashing all branch commits since the merge-base into one reindex commit containing the renamed files. + +If the branch has already been pushed, finishing the reindex will require a `--force-with-lease` push. The skill asks for explicit consent before rewriting history. ## Interaction style -Use the `AskUserQuestion` tool when prompting for the commit decision at the end. Everything else is deterministic and runs without user input. +Use the `AskUserQuestion` tool when prompting for consent and at the finish step. Everything else is deterministic. **Do not redirect any command output to `/tmp` files.** The scripts in this skill emit only what you need to act on; piping to `/tmp/*.txt`, `tee`-ing into scratch files, or stashing stderr separately is unnecessary and creates clutter outside the repo. If a command's output is too long to read in one go, narrow it (`| tail -N`, `| head -N`, or pass a more specific flag) rather than persisting it. @@ -26,33 +28,40 @@ Shared scripts: Skill-specific scripts: ``` +scripts/resolve-base.sh scripts/plan-renames.sh scripts/find-collisions.sh scripts/list-taken-ids.sh scripts/rename-decision.sh +scripts/commit-reindex.sh ``` ## Prerequisites 1. Check that `dld.config.yaml` exists at the repo root. If not, tell the user to run `/dld-init` first and stop. 2. Verify the working tree is clean (`git status --porcelain` empty). If not, ask the user to commit or stash first, then stop. -3. Determine the base branch. Prefer the upstream of the current branch: - ```bash - git rev-parse --abbrev-ref --symbolic-full-name @{upstream} - ``` - If there is no upstream, default to `origin/main`. The user can override by passing a base ref to this skill (e.g. `/dld-reindex origin/develop`). -4. Fetch the base ref so collision detection sees the latest state: +3. Fetch the latest base state: ```bash git fetch origin ``` -## Step 1: Plan the renames +## Step 1: Resolve the base ref + +```bash +BASE=$(bash scripts/resolve-base.sh) +``` + +`resolve-base.sh` prefers the branch's upstream when it tracks a *different* branch (the typical "feature → main" setup). It falls back to `origin/main` if the upstream is unset OR if the upstream tracks the same branch name as the current branch (i.e. it's just the remote copy of this same branch, not a useful collision base). + +The user may pass an explicit base when invoking the skill (e.g. `/dld-reindex origin/develop`) — honor it if present. + +## Step 2: Plan the renames ```bash bash scripts/plan-renames.sh --base "$BASE" ``` -This combines collision detection, the `gh`-aware "taken IDs" scan, and free-ID assignment into a single deterministic plan. Output is tab-separated, one rename per line: +Output is tab-separated, one rename per line: ``` \t\t @@ -62,69 +71,90 @@ If the output is empty, exit with: > No ID collisions detected. Safe to rebase onto `$BASE`. -`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed` (from the underlying `list-taken-ids.sh`). **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. To read stderr alongside stdout in a single Bash call, append `2>&1` — do not split the output into separate `/tmp` files. +`plan-renames.sh` may print a stderr note like `[dld-reindex] open PRs not scanned: gh CLI not installed`. **Always surface this to the user** so they know the renamed IDs were chosen against base-branch state only and may still collide with an open PR. The underlying helpers (`find-collisions.sh`, `list-taken-ids.sh`) remain available for debugging, but the SKILL flow always goes through `plan-renames.sh`. -## Step 2: Apply renames +## Step 3: Get explicit consent for the history rewrite -For each line in the plan output, call: +Show the user the rename plan and the implication. Use `AskUserQuestion`: + +> Resolving these collisions requires rewriting branch history. I will squash the N commits since `` into a single reindex commit. The original commit subjects will be preserved in the new commit body. If the branch has already been pushed, finishing will require `git push --force-with-lease`. How should I proceed? + +Options: +- **Rewrite and force-push** — agent applies renames, squashes, commits, and runs `git push --force-with-lease`. +- **Rewrite only** — agent applies renames, squashes, and commits. User pushes when ready. +- **Cancel** — abort with no changes. + +If the user cancels, exit without touching anything. + +## Step 4: Apply renames + +For each line in the plan, call: ```bash bash scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path --base "$BASE" ``` -`rename-decision.sh` does all of the following in one call: +`rename-decision.sh` does all of: -- `git mv` the file from `DL-OLD.md` to `DL-NEW.md` (preserves rename history). +- `git mv` the file from `DL-OLD.md` to `DL-NEW.md`. - Patches the `id:` frontmatter field in the renamed file. -- Rewrites `DL-OLD` mentions inside the renamed file's body (so internal cross-references survive the rename). -- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). It scopes this to the local change set vs the base ref, so it never touches decisions that already existed on the base. +- Rewrites `DL-OLD` mentions inside the renamed file's body. +- Rewrites `DL-OLD` mentions inside OTHER locally-added/modified decision files (frontmatter `supersedes` / `amends` / `references` and body). Scoped to the local change set vs the base ref. - Rewrites `` `@decision` ``(DL-OLD) annotations to `` `@decision` ``(DL-NEW) in non-decision files that are part of the local change set. The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. -## Step 3: Regenerate INDEX.md +## Step 5: Squash and commit -Pass `--include-base` so the regenerated INDEX merges in decisions that exist on the base branch but not yet locally. Without this flag, the regenerated INDEX would only contain rows the branch knows about, and the subsequent rebase would conflict with main's INDEX rows for decisions added on main since the branch diverged. +Pipe the rename plan into `commit-reindex.sh`: ```bash -bash ../dld-common/scripts/regenerate-index.sh --include-base "$BASE" +echo "$PLAN" | bash scripts/commit-reindex.sh --base "$BASE" ``` -## Step 4: Ask about committing - -Present the rename table to the user (old ID → new ID, one row per rename) and ask via `AskUserQuestion`: +This: -> How should I finish this reindex? +1. Computes the merge-base with `$BASE`. +2. Mixed-resets HEAD to the merge-base (working tree is preserved — it already holds the post-rename state from step 4). +3. Restores `INDEX.md` in the working tree to its merge-base state so the working tree is consistent with what we're about to commit. **INDEX.md is intentionally excluded from the reindex commit** — including it would cause a content conflict during rebase whenever the base branch also modified INDEX.md (git's 3-way merge fails to align both sides' top-of-file inserts even when row content overlaps). INDEX.md gets regenerated post-rebase instead. +4. Stages **only** an explicit path list derived from the original branch diff and the rename plan — the old paths (for deletions), the new paths (for additions), every other file the branch touched. Untracked unrelated paths (e.g. `.claude/worktrees`, scratch files, in-progress edits to unrelated files) are deliberately NOT swept in. +5. Commits with a templated message that lists the renames in the subject and preserves the original branch commits' subjects in the body. -Options (single-select): +**Do not use `git add -A` or `git commit -a` anywhere in this flow.** Use only `commit-reindex.sh` to commit. Targeting paths explicitly is the whole point of this step. -- **Commit and push** — agent runs `git add -A && git commit && git push`. -- **Commit only** — agent runs `git add -A && git commit`, leaves the push to the user. -- **Leave for me** — agent stages nothing further; the working-tree state stays as rename-decision.sh left it (the `git mv`'s already register the renames in the index). +## Step 6: Push (if the user chose force-push) -For "Commit and push" / "Commit only", use this commit message format: +If step 3's answer was "Rewrite and force-push": -``` -reindex local decisions: DL-OLD1 -> DL-NEW1, DL-OLD2 -> DL-NEW2 +```bash +if git rev-parse --verify --quiet "@{upstream}" >/dev/null; then + git push --force-with-lease +else + git push -u origin HEAD +fi ``` -For more than three renames, summarize as `reindex N local decisions to avoid base-branch collisions` and put the full table in the body. +`--force-with-lease` is mandatory over `--force` here — it refuses to push if the remote moved since the last fetch, which protects against overwriting concurrent collaborator pushes. -## Step 5: Report +## Step 7: Report Print: -- The renames table (always). -- The stderr note from step 1 if `gh` was skipped (always — even if the user chose to commit). -- The next-step hint: +- The renames table. +- The stderr note from step 2 if `gh` was skipped. +- The number of commits squashed. +- The next steps: -> Next step: `git rebase $BASE` +> 1. `git rebase $BASE` +> 2. `bash ../dld-common/scripts/regenerate-index.sh` (to repopulate INDEX.md with the renamed locals — the reindex commit intentionally leaves INDEX.md alone to keep the rebase conflict-free; INDEX.md is missing the renamed rows until you regenerate) +> 3. Commit the INDEX.md update The skill never rebases or merges — that is always the user's call. ## Out of scope -- **Already-conflicted rebases.** This skill is pre-rebase only. If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. +- **Already-conflicted rebases.** If the user is mid-rebase with conflicts, tell them to `git rebase --abort` first and re-run this skill. +- **Preserving per-commit granularity.** The squash trades original commit boundaries for a deterministic rewrite. A future `--preserve-history` flag could perform a cherry-pick walk that rewrites each commit individually, but the edge cases (commits modifying an already-renamed file, merge commits, partial reruns) make it materially more complex than the squash. - **Cross-namespace ID reconciliation** in namespaced projects. IDs are assumed globally unique across namespaces, matching `next-id.sh`. diff --git a/skills/dld-reindex/scripts/commit-reindex.sh b/skills/dld-reindex/scripts/commit-reindex.sh new file mode 100755 index 0000000..252700e --- /dev/null +++ b/skills/dld-reindex/scripts/commit-reindex.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Squash the branch's commits since the merge-base with $BASE into a single +# reindex commit. Required because if the original branch added decision files +# at colliding paths (e.g. DL-205.md), those paths exist in the branch's HISTORY +# even after a later rename — and `git rebase` will hit an add/add conflict on +# the original add commit. Rewriting history so the colliding paths never +# appear in any branch commit is the only reliable fix. +# +# Stages an EXPLICIT path list derived from the original branch diff (mapped +# through the rename plan). Untracked unrelated paths (.claude/worktrees, +# scratch dirs, in-progress unrelated edits) are never swept in — `git add -A` +# with no pathspec is deliberately avoided. +# +# INDEX.md is INTENTIONALLY excluded from the commit and restored in the +# working tree to its merge-base state. Including it would cause a content +# conflict during rebase whenever the base branch also modified INDEX.md, since +# both sides insert rows at the top of the same file (git's 3-way merge fails +# to align them even when row content overlaps). The post-rebase step in the +# SKILL is to regenerate INDEX.md once, which is conflict-free. +# +# Reads the rename plan from stdin, one rename per line (tab-separated): +# \t\t +# +# Usage: bash commit-reindex.sh --base + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$BASE" ]]; then + echo "Error: --base is required." >&2 + exit 1 +fi + +PROJECT_ROOT="$(get_project_root)" +cd "$PROJECT_ROOT" + +if ! git rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + echo "Error: base ref '$BASE' not found." >&2 + exit 1 +fi + +PLAN=$(cat) +if [[ -z "$PLAN" ]]; then + echo "Error: no rename plan on stdin." >&2 + exit 1 +fi + +MERGE_BASE=$(git merge-base "$BASE" HEAD) + +if [[ "$MERGE_BASE" == "$(git rev-parse HEAD)" ]]; then + echo "Error: HEAD is already at the merge-base — nothing to squash." >&2 + exit 1 +fi + +# Files touched by branch commits since merge-base (pre-rename perspective). +BRANCH_FILES=$(git diff --name-only --diff-filter=AMRD "$MERGE_BASE"..HEAD) + +# Build the explicit stage set. We collect every relevant path so we can +# `git add -A --` each one (which handles add/modify/delete uniformly). +DECISIONS_DIR_REL="$(config_get decisions_dir)" +PATHS=() + +# Renamed files: stage BOTH old and new paths so that deletions of the old +# (if it ever existed in merge-base) and additions of the new are captured. +while IFS=$'\t' read -r old_path old_id new_id; do + [[ -z "$old_path" ]] && continue + new_path="$(dirname "$old_path")/$new_id.md" + PATHS+=("$old_path" "$new_path") +done <<< "$PLAN" + +INDEX_PATH="$DECISIONS_DIR_REL/INDEX.md" + +# Other branch-touched files (annotation rewrites land here). +# INDEX.md is deliberately excluded — see the header comment. +while IFS= read -r f; do + [[ -z "$f" ]] && continue + [[ "$f" == "$INDEX_PATH" ]] && continue + PATHS+=("$f") +done <<< "$BRANCH_FILES" + +# Capture original commit subjects (for the new commit body) BEFORE we move HEAD. +ORIGINAL_COMMITS=$(git log --reverse --format='- %s' "$MERGE_BASE"..HEAD) + +# Build the rename summary for the subject line. +RENAME_COUNT=0 +RENAMES="" +RENAME_LIST="" +while IFS=$'\t' read -r _ old_id new_id; do + [[ -z "$old_id" ]] && continue + RENAME_COUNT=$((RENAME_COUNT + 1)) + if [[ -n "$RENAMES" ]]; then + RENAMES+=", " + fi + RENAMES+="${old_id} -> ${new_id}" + RENAME_LIST+="- ${old_id} -> ${new_id}"$'\n' +done <<< "$PLAN" + +if [[ "$RENAME_COUNT" -gt 3 ]]; then + SUBJECT="reindex $RENAME_COUNT local decisions to avoid base-branch collisions" +else + SUBJECT="reindex local decisions: $RENAMES" +fi + +# Mixed reset to merge-base — moves HEAD, clears the index, leaves the working tree alone. +git reset --quiet "$MERGE_BASE" + +# Restore INDEX.md in the working tree to match merge-base state (or remove +# it entirely if merge-base didn't have it). This keeps the working tree +# consistent with what we're about to commit (which excludes INDEX.md). +if git cat-file -e "HEAD:$INDEX_PATH" 2>/dev/null; then + git checkout HEAD -- "$INDEX_PATH" +elif [[ -f "$INDEX_PATH" ]]; then + rm -f "$INDEX_PATH" +fi + +# Dedup the path list and stage each explicitly. +printf '%s\n' "${PATHS[@]}" | sort -u | while IFS= read -r p; do + [[ -z "$p" ]] && continue + # `git add -A --` on a single pathspec stages add/modify/delete for THAT path only. + # If the path doesn't exist on disk and isn't in the index, git is a no-op + non-zero; + # swallow that case rather than abort. + git add -A -- "$p" 2>/dev/null || true +done + +if git diff --cached --quiet; then + echo "Error: nothing to commit after squash. The reindex may have already been applied, or the plan didn't match the branch state." >&2 + exit 1 +fi + +# Build the full message. +if [[ -n "$ORIGINAL_COMMITS" ]]; then + FULL_MSG="$SUBJECT + +Renames: +${RENAME_LIST% +} + +Squashed from original branch commits: +$ORIGINAL_COMMITS" +else + FULL_MSG="$SUBJECT + +Renames: +${RENAME_LIST% +}" +fi + +git commit --quiet -m "$FULL_MSG" +NEW_HEAD=$(git rev-parse --short HEAD) +echo "Created reindex commit $NEW_HEAD on top of $(git rev-parse --short "$MERGE_BASE")" diff --git a/skills/dld-reindex/scripts/resolve-base.sh b/skills/dld-reindex/scripts/resolve-base.sh new file mode 100755 index 0000000..426efa4 --- /dev/null +++ b/skills/dld-reindex/scripts/resolve-base.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Resolve the base ref for /dld-reindex. +# +# Prefers the branch's upstream when it tracks a DIFFERENT branch name +# (the typical "feature → main" setup). Falls back to origin/main when: +# * No upstream is configured. +# * Upstream tracks a branch with the same name as the current branch +# (e.g. current=test/dld-reindex-dummy, upstream=origin/test/dld-reindex-dummy +# — that's the remote copy of the same branch, not a useful collision base). +# +# Outputs the resolved ref. Exits non-zero only if it can't resolve anything. + +set -euo pipefail + +CURRENT=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + +UPSTREAM="" +if UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "@{upstream}" 2>/dev/null); then + UPSTREAM_BRANCH="${UPSTREAM#*/}" + if [[ -n "$UPSTREAM_BRANCH" && "$UPSTREAM_BRANCH" != "$CURRENT" ]]; then + echo "$UPSTREAM" + exit 0 + fi +fi + +echo "origin/main" diff --git a/tests/test_reindex.bats b/tests/test_reindex.bats index 6e58ea8..4549791 100644 --- a/tests/test_reindex.bats +++ b/tests/test_reindex.bats @@ -11,7 +11,10 @@ setup() { REINDEX_DIR="$SKILLS_DIR/dld-reindex/scripts" # Establish a `main` branch with one decision already on it, then branch off. + # Seed INDEX.md alongside so the merge-base has it — mirrors a real project + # where /dld-init creates INDEX.md before any branch diverges. create_decision "DL-001" "accepted" + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null git add -A git commit --quiet -m "seed main" git branch -M main @@ -88,6 +91,34 @@ teardown() { assert_output --partial "DL-001" } +# --- resolve-base.sh --------------------------------------------------------- + +@test "resolve-base: returns upstream when it differs from the current branch" { + # Stand up a fake remote so @{upstream} resolves to a different branch. + git update-ref refs/remotes/origin/main HEAD + git config branch.feature.remote origin + git config branch.feature.merge refs/heads/main + run bash "$REINDEX_DIR/resolve-base.sh" + assert_success + assert_output "origin/main" +} + +@test "resolve-base: falls back to origin/main when upstream equals current branch" { + # Simulate "the branch's upstream is its own remote copy". + git update-ref refs/remotes/origin/feature HEAD + git config branch.feature.remote origin + git config branch.feature.merge refs/heads/feature + run bash "$REINDEX_DIR/resolve-base.sh" + assert_success + assert_output "origin/main" +} + +@test "resolve-base: falls back to origin/main when no upstream is configured" { + run bash "$REINDEX_DIR/resolve-base.sh" + assert_success + assert_output "origin/main" +} + # --- plan-renames.sh --------------------------------------------------------- @test "plan-renames: empty output when there are no collisions" { @@ -305,3 +336,169 @@ EOF assert_failure assert_output --partial "DL-[0-9]+" } + +# --- commit-reindex.sh ------------------------------------------------------- + +@test "commit-reindex: squashes branch commits into a single reindex commit" { + # Two branch commits, both adding a colliding decision. + git checkout main --quiet + create_decision "DL-002" "accepted" + git add -A + git commit --quiet -m "land DL-002 on main" + git checkout feature --quiet + + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "feature: draft DL-002" + + mkdir -p src + echo "# @decision(DL-002)" > src/foo.txt + git add -A + git commit --quiet -m "feature: add annotation" + + # Apply the reindex flow as the SKILL would. INDEX.md is deliberately NOT + # regenerated here — commit-reindex.sh leaves INDEX.md at merge-base state. + PLAN=$(bash "$REINDEX_DIR/plan-renames.sh" --base main 2>/dev/null) + echo "$PLAN" | while IFS=$'\t' read -r path old new; do + bash "$REINDEX_DIR/rename-decision.sh" --old "$old" --new "$new" --path "$path" --base main + done >/dev/null + echo "$PLAN" | bash "$REINDEX_DIR/commit-reindex.sh" --base main >/dev/null + + # Exactly one commit on feature above merge-base. + MERGE_BASE=$(git merge-base main HEAD) + run git log --format='%s' "$MERGE_BASE"..HEAD + assert_success + assert_line --index 0 --partial "reindex local decisions" + + COMMIT_COUNT=$(git log --format='%s' "$MERGE_BASE"..HEAD | wc -l | tr -d ' ') + [[ "$COMMIT_COUNT" -eq 1 ]] +} + +@test "commit-reindex: leaves INDEX.md at merge-base state (not modified by the reindex commit)" { + git checkout main --quiet + create_decision "DL-002" "accepted" + git add -A + git commit --quiet -m "land DL-002 on main" + git checkout feature --quiet + + create_decision "DL-002" "proposed" + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null + git add -A + git commit --quiet -m "feature: draft DL-002 + INDEX update" + + PLAN=$(bash "$REINDEX_DIR/plan-renames.sh" --base main 2>/dev/null) + echo "$PLAN" | while IFS=$'\t' read -r path old new; do + bash "$REINDEX_DIR/rename-decision.sh" --old "$old" --new "$new" --path "$path" --base main + done >/dev/null + echo "$PLAN" | bash "$REINDEX_DIR/commit-reindex.sh" --base main >/dev/null + + # The reindex commit's INDEX.md must match the merge-base's INDEX.md exactly. + MERGE_BASE=$(git merge-base main HEAD) + run git diff "$MERGE_BASE" HEAD -- decisions/INDEX.md + assert_success + assert_output "" + + # Working tree's INDEX.md also matches (so the user doesn't see uncommitted changes). + run git status --porcelain decisions/INDEX.md + assert_output "" +} + +@test "commit-reindex: does NOT sweep in untracked unrelated paths" { + git checkout main --quiet + create_decision "DL-002" "accepted" + git add -A + git commit --quiet -m "land DL-002 on main" + git checkout feature --quiet + + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "feature: draft DL-002" + + # Drop an untracked file that should NOT end up in the squashed commit. + mkdir -p .claude/worktrees + echo "scratch" > .claude/worktrees/random.txt + echo "stray" > untracked-stray.txt + + PLAN=$(bash "$REINDEX_DIR/plan-renames.sh" --base main 2>/dev/null) + echo "$PLAN" | while IFS=$'\t' read -r path old new; do + bash "$REINDEX_DIR/rename-decision.sh" --old "$old" --new "$new" --path "$path" --base main + done >/dev/null + echo "$PLAN" | bash "$REINDEX_DIR/commit-reindex.sh" --base main >/dev/null + + # The committed tree must NOT contain the untracked stray files. + run git ls-tree -r HEAD + refute_output --partial ".claude/worktrees/random.txt" + refute_output --partial "untracked-stray.txt" + + # They should still be present on disk (we didn't delete them). + [[ -f .claude/worktrees/random.txt ]] + [[ -f untracked-stray.txt ]] +} + +@test "commit-reindex: errors out when stdin plan is empty" { + run bash -c "echo '' | bash \"$REINDEX_DIR/commit-reindex.sh\" --base main" + assert_failure + assert_output --partial "no rename plan" +} + +# --- end-to-end -------------------------------------------------------------- + +@test "end-to-end: post-reindex branch rebases cleanly onto main" { + # main lands DL-002 + DL-003 + DL-004 after the branch point. + git checkout main --quiet + create_decision "DL-002" "accepted" + create_decision "DL-003" "accepted" + create_decision "DL-004" "accepted" + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null + git add -A + git commit --quiet -m "land DL-002 DL-003 DL-004" + git checkout feature --quiet + + # feature drafts DL-002 and DL-003 with annotations. + create_decision "DL-002" "proposed" + create_decision "DL-003" "proposed" + mkdir -p src + echo "# @decision(DL-002)" > src/auth.py + echo "# @decision(DL-003)" > src/billing.py + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null + git add -A + git commit --quiet -m "feature: draft DL-002 and DL-003 + annotations" + + # Run the SKILL's flow (no INDEX regen during reindex — commit-reindex.sh + # leaves INDEX.md alone so the rebase is conflict-free). + PLAN=$(bash "$REINDEX_DIR/plan-renames.sh" --base main 2>/dev/null) + echo "$PLAN" | while IFS=$'\t' read -r path old new; do + bash "$REINDEX_DIR/rename-decision.sh" --old "$old" --new "$new" --path "$path" --base main + done >/dev/null + echo "$PLAN" | bash "$REINDEX_DIR/commit-reindex.sh" --base main >/dev/null + + # Now rebase onto main — this used to fail with an add/add conflict. + run git rebase main + assert_success + + # After rebase: main's DL-002 and DL-003 exist, and the renamed locals exist. + [[ -f decisions/records/DL-002.md ]] + [[ -f decisions/records/DL-003.md ]] + [[ -f decisions/records/DL-005.md ]] + [[ -f decisions/records/DL-006.md ]] + + # The DL-002 on disk is main's version (status: accepted), not the draft. + run grep '^status:' decisions/records/DL-002.md + assert_output --partial "accepted" + + # Annotations were rewritten to the new IDs. + run cat src/auth.py + assert_output --partial "@decision(DL-005)" + run cat src/billing.py + assert_output --partial "@decision(DL-006)" + + # INDEX.md post-rebase matches main's INDEX.md (missing rows for renamed + # locals — the user needs to regenerate as documented). + run grep -c "DL-005\|DL-006" decisions/INDEX.md + assert_output "0" + + # Regenerating INDEX.md post-rebase repopulates the renamed rows. + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null + run grep -c "DL-005\|DL-006" decisions/INDEX.md + refute_output "0" +} From d7ba637fdf88fa8778974e4a6d20c0911d27de11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 15:48:47 +0200 Subject: [PATCH 6/8] README: cover /dld-reindex and clean up roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Skills table row, a "Working in a team" subsection describing when to reach for the skill, and drops the stale roadmap line that referenced a differently-scoped /dld-reindex (sync decision references after refactors, issue #8) — the name now belongs to collision resolution. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3155286..173de79 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ Use `/dld-retrofit` to generate decisions from code that already exists: This works as a standalone "document this codebase" action. You get structured decision records, code annotations, and a generated system overview. From there you can adopt the full workflow, or just re-run `/dld-audit-auto` and `/dld-snapshot` on a schedule to keep documentation in sync. +### Working in a team + +When multiple developers draft decisions in parallel, two of them can end up picking the same `DL-NNN` ID. Once one of those PRs lands on the base branch, the other can't rebase cleanly — the colliding decision file path appears in both histories. + +``` +/dld-reindex # Renames the local draft(s) to the next free ID, + # rewrites @decision annotations and cross-references, + # squashes the branch into a single rebase-clean commit +``` + +Run this before rebasing onto an updated base. The skill resolves the ID against the base branch and, when `gh` is installed and authenticated, also against open PRs so the new IDs don't collide with someone else's in-flight work. + ## How it works DLD is implemented as a set of AI agent skills following the [Agent Skills](https://agentskills.io) open standard. @@ -128,6 +140,7 @@ DLD is designed for long-lived codebases where decisions accumulate, original au | `/dld-audit-auto` | Autonomous audit — detects drift, fixes issues, opens a PR (for scheduled/CI use) | | `/dld-snapshot` | Generate SNAPSHOT.md (detailed reference) and OVERVIEW.md (narrative synthesis with diagrams) | | `/dld-retrofit` | Bootstrap decisions from an existing codebase (broad or detailed mode) | +| `/dld-reindex` | Resolve decision ID collisions with the base branch (and open PRs) before rebasing — renames colliding local drafts, rewrites annotations and cross-references, squashes branch commits into a rebase-clean reindex commit | ### Active workflow @@ -265,12 +278,7 @@ See the [concept paper](docs/concept/dld-concept.md) for a detailed discussion o ## Roadmap -DLD is under active development. Some planned additions: - -- **`/dld-reindex`** — Sync decision `references` after code refactors by scanning `@decision` annotations ([#8](https://github.com/jimutt/dld-kit/issues/8)) -- **Extended snapshot artifacts** — Custom documentation outputs from `/dld-snapshot` via configuration ([#2](https://github.com/jimutt/dld-kit/issues/2)) ✅ - -Feature requests and ideas are welcome — [open an issue](https://github.com/jimutt/dld-kit/issues). +DLD is under active development. Feature requests and ideas are welcome — [open an issue](https://github.com/jimutt/dld-kit/issues). ## Manual CLAUDE.md setup From 0cdf1f5b08d17b7599477fe6383682fa0b78d609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 16:24:07 +0200 Subject: [PATCH 7/8] Fix /dld-reindex multi-rename cross-ref bug; add stale-mention review step Two fixes from real-world testing: 1. rename-decision.sh computed CHANGED_FILES from "$BASE"...HEAD (committed state only). On a multi-rename run, the second invocation saw the OLD path from the first rename (now gone from disk) instead of the NEW path with its uncommitted state, so cross-references in the first-renamed file's body to the second-renamed ID were never updated. Compute against $BASE alone so working-tree state participates, and add R to the diff filter + --find-renames so post-mv detection is robust. 2. Plain-text DL-OLD mentions in code comments / log strings / prose were silently left alone. Blanket substitution risks false positives, so add a new find-stale-mentions.sh that surfaces those mentions with file, line, and content; the SKILL gains a Step 5 where the agent reviews each one in context and decides whether to update it via Edit (not a blanket replace). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/dld-reindex/SKILL.md | 28 ++++- .../scripts/find-stale-mentions.sh | 75 ++++++++++++ .../dld-reindex/scripts/rename-decision.sh | 10 +- skills/dld-reindex/SKILL.md | 28 ++++- .../scripts/find-stale-mentions.sh | 75 ++++++++++++ skills/dld-reindex/scripts/rename-decision.sh | 10 +- tests/test_reindex.bats | 115 ++++++++++++++++++ 7 files changed, 331 insertions(+), 10 deletions(-) create mode 100755 .claude/skills/dld-reindex/scripts/find-stale-mentions.sh create mode 100755 skills/dld-reindex/scripts/find-stale-mentions.sh diff --git a/.claude/skills/dld-reindex/SKILL.md b/.claude/skills/dld-reindex/SKILL.md index 9b87800..328d072 100644 --- a/.claude/skills/dld-reindex/SKILL.md +++ b/.claude/skills/dld-reindex/SKILL.md @@ -33,6 +33,7 @@ Skill-specific scripts: .claude/skills/dld-reindex/scripts/find-collisions.sh .claude/skills/dld-reindex/scripts/list-taken-ids.sh .claude/skills/dld-reindex/scripts/rename-decision.sh +.claude/skills/dld-reindex/scripts/find-stale-mentions.sh .claude/skills/dld-reindex/scripts/commit-reindex.sh ``` @@ -106,7 +107,28 @@ bash .claude/skills/dld-reindex/scripts/rename-decision.sh --old DL-OLD --new DL The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. -## Step 5: Squash and commit +**Note on plain-text DL-NNN mentions in code:** In non-decision files (source code, READMEs, etc.) `rename-decision.sh`'s rewrite is **scoped to `` `@decision` ``(DL-NNN) annotations only**. Bare `DL-NNN` references in comments, log strings, or test fixtures are left untouched here to avoid false-positive matches against unrelated identifiers. Step 5 handles them. + +## Step 5: Review plain-text DL-OLD mentions + +After all renames are applied (but before the squash), find any remaining bare `DL-OLD` references in non-decision changed files: + +```bash +echo "$PLAN" | bash .claude/skills/dld-reindex/scripts/find-stale-mentions.sh --base "$BASE" +``` + +Output is tab-separated, one match per line: `\t\t\t\t`. Empty output means nothing to review. + +For each match: + +1. Read the surrounding context in the file. +2. Decide whether the reference makes sense as `DL-OLD` or should become `DL-NEW`. A code comment like `// Span-driven batching (DL-207) groups resolutions sharing the same turn context (DL-202)` after a `DL-207 → DL-213` rename almost certainly wants `DL-213` (it's prose alongside an annotation). A test fixture string that's checking historical data may need to stay as `DL-OLD`. +3. If the line should change, use the `Edit` tool to update that specific occurrence — don't do a blanket find-and-replace. +4. If the line should stay, leave it. + +Surface the list of matches to the user with your verdicts before you finish, so they can sanity-check the judgment calls. + +## Step 6: Squash and commit Pipe the rename plan into `commit-reindex.sh`: @@ -124,7 +146,7 @@ This: **Do not use `git add -A` or `git commit -a` anywhere in this flow.** Use only `commit-reindex.sh` to commit. Targeting paths explicitly is the whole point of this step. -## Step 6: Push (if the user chose force-push) +## Step 7: Push (if the user chose force-push) If step 3's answer was "Rewrite and force-push": @@ -138,7 +160,7 @@ fi `--force-with-lease` is mandatory over `--force` here — it refuses to push if the remote moved since the last fetch, which protects against overwriting concurrent collaborator pushes. -## Step 7: Report +## Step 8: Report Print: diff --git a/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh b/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh new file mode 100755 index 0000000..cd55783 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Find plain-text DL-OLD mentions in locally-changed non-decision files that +# rename-decision.sh did NOT rewrite. rename-decision.sh only touches +# `@decision(DL-OLD)` annotations in code, deliberately leaving bare `DL-NNN` +# mentions alone — substituting them blindly risks false-positive matches +# against unrelated identifiers. This script surfaces the bare mentions so the +# agent can review each one in context and decide whether it should be updated. +# +# Reads the rename plan from stdin (same tab-separated format plan-renames.sh +# emits): +# \t\t +# +# Output (stdout): one mention per line, tab-separated: +# \t\t\t\t +# Empty output means there's nothing to review. Run this AFTER all renames have +# been applied — the script greps the post-rename working tree, so any +# `@decision(DL-OLD)` annotations that rename-decision.sh already rewrote to +# `@decision(DL-NEW)` will not appear in results. +# +# Usage: bash find-stale-mentions.sh --base + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$BASE" ]]; then + echo "Error: --base is required." >&2 + exit 1 +fi + +PROJECT_ROOT="$(get_project_root)" +cd "$PROJECT_ROOT" + +PLAN=$(cat) +if [[ -z "$PLAN" ]]; then + exit 0 +fi + +DECISIONS_DIR_REL="$(config_get decisions_dir)" + +# Locally-changed files (working-tree state included — matches rename-decision.sh). +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) + +if [[ -z "$CHANGED_FILES" ]]; then + exit 0 +fi + +while IFS=$'\t' read -r _path old_id new_id; do + [[ -z "$old_id" ]] && continue + + while IFS= read -r f; do + [[ -z "$f" || ! -f "$f" ]] && continue + # Decision files are handled inside rename-decision.sh's substitution pass. + [[ "$f" == "$DECISIONS_DIR_REL"/* ]] && continue + + # Digit-aware: only match DL-OLD when not followed by another digit, so + # renaming DL-200 does not surface bogus matches inside DL-2000. + # `|| true` keeps a no-match grep from aborting the script under set -e/pipefail. + matches=$(grep -nE "${old_id}([^0-9]|\$)" "$f" 2>/dev/null || true) + [[ -z "$matches" ]] && continue + while IFS=: read -r lineno rest; do + [[ -z "$lineno" ]] && continue + printf '%s\t%s\t%s\t%s\t%s\n' "$f" "$lineno" "$old_id" "$new_id" "$rest" + done <<< "$matches" + done <<< "$CHANGED_FILES" +done <<< "$PLAN" diff --git a/.claude/skills/dld-reindex/scripts/rename-decision.sh b/.claude/skills/dld-reindex/scripts/rename-decision.sh index bf1441c..41907ec 100755 --- a/.claude/skills/dld-reindex/scripts/rename-decision.sh +++ b/.claude/skills/dld-reindex/scripts/rename-decision.sh @@ -81,8 +81,14 @@ git mv "$INPUT_PATH" "$NEW_PATH" sed_inplace "$NEW_PATH" -e "s/^id:[[:space:]]*${OLD}\$/id: ${NEW}/" substitute_in_file "$NEW_PATH" -# 3. Determine the local change set (files added or modified vs the base ref). -CHANGED_FILES=$(git diff --name-only --diff-filter=AM "$BASE"...HEAD 2>/dev/null || true) +# 3. Determine the local change set: files added/modified/renamed vs the base ref, +# INCLUDING uncommitted working-tree state. Using `$BASE` (no `..HEAD`) lets the +# diff include working-tree changes — so when this script is called repeatedly +# during a multi-rename run, each invocation sees the renamed files produced by +# the previous calls (the new paths) instead of the stale committed paths (the +# old paths, now gone from disk). Without this, only the *last* rename's +# new-path file would have its cross-references updated to subsequent renames. +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) DECISIONS_DIR_REL="$(config_get decisions_dir)" ANNOTATION_PREFIX="$(config_get annotation_prefix)" diff --git a/skills/dld-reindex/SKILL.md b/skills/dld-reindex/SKILL.md index c98f229..45d08ab 100644 --- a/skills/dld-reindex/SKILL.md +++ b/skills/dld-reindex/SKILL.md @@ -33,6 +33,7 @@ scripts/plan-renames.sh scripts/find-collisions.sh scripts/list-taken-ids.sh scripts/rename-decision.sh +scripts/find-stale-mentions.sh scripts/commit-reindex.sh ``` @@ -106,7 +107,28 @@ bash scripts/rename-decision.sh --old DL-OLD --new DL-NEW --path The substitution is digit-aware: renaming `DL-100` will not accidentally rewrite `DL-1000`. -## Step 5: Squash and commit +**Note on plain-text DL-NNN mentions in code:** In non-decision files (source code, READMEs, etc.) `rename-decision.sh`'s rewrite is **scoped to `` `@decision` ``(DL-NNN) annotations only**. Bare `DL-NNN` references in comments, log strings, or test fixtures are left untouched here to avoid false-positive matches against unrelated identifiers. Step 5 handles them. + +## Step 5: Review plain-text DL-OLD mentions + +After all renames are applied (but before the squash), find any remaining bare `DL-OLD` references in non-decision changed files: + +```bash +echo "$PLAN" | bash scripts/find-stale-mentions.sh --base "$BASE" +``` + +Output is tab-separated, one match per line: `\t\t\t\t`. Empty output means nothing to review. + +For each match: + +1. Read the surrounding context in the file. +2. Decide whether the reference makes sense as `DL-OLD` or should become `DL-NEW`. A code comment like `// Span-driven batching (DL-207) groups resolutions sharing the same turn context (DL-202)` after a `DL-207 → DL-213` rename almost certainly wants `DL-213` (it's prose alongside an annotation). A test fixture string that's checking historical data may need to stay as `DL-OLD`. +3. If the line should change, use the `Edit` tool to update that specific occurrence — don't do a blanket find-and-replace. +4. If the line should stay, leave it. + +Surface the list of matches to the user with your verdicts before you finish, so they can sanity-check the judgment calls. + +## Step 6: Squash and commit Pipe the rename plan into `commit-reindex.sh`: @@ -124,7 +146,7 @@ This: **Do not use `git add -A` or `git commit -a` anywhere in this flow.** Use only `commit-reindex.sh` to commit. Targeting paths explicitly is the whole point of this step. -## Step 6: Push (if the user chose force-push) +## Step 7: Push (if the user chose force-push) If step 3's answer was "Rewrite and force-push": @@ -138,7 +160,7 @@ fi `--force-with-lease` is mandatory over `--force` here — it refuses to push if the remote moved since the last fetch, which protects against overwriting concurrent collaborator pushes. -## Step 7: Report +## Step 8: Report Print: diff --git a/skills/dld-reindex/scripts/find-stale-mentions.sh b/skills/dld-reindex/scripts/find-stale-mentions.sh new file mode 100755 index 0000000..cd55783 --- /dev/null +++ b/skills/dld-reindex/scripts/find-stale-mentions.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Find plain-text DL-OLD mentions in locally-changed non-decision files that +# rename-decision.sh did NOT rewrite. rename-decision.sh only touches +# `@decision(DL-OLD)` annotations in code, deliberately leaving bare `DL-NNN` +# mentions alone — substituting them blindly risks false-positive matches +# against unrelated identifiers. This script surfaces the bare mentions so the +# agent can review each one in context and decide whether it should be updated. +# +# Reads the rename plan from stdin (same tab-separated format plan-renames.sh +# emits): +# \t\t +# +# Output (stdout): one mention per line, tab-separated: +# \t\t\t\t +# Empty output means there's nothing to review. Run this AFTER all renames have +# been applied — the script greps the post-rename working tree, so any +# `@decision(DL-OLD)` annotations that rename-decision.sh already rewrote to +# `@decision(DL-NEW)` will not appear in results. +# +# Usage: bash find-stale-mentions.sh --base + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +BASE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$BASE" ]]; then + echo "Error: --base is required." >&2 + exit 1 +fi + +PROJECT_ROOT="$(get_project_root)" +cd "$PROJECT_ROOT" + +PLAN=$(cat) +if [[ -z "$PLAN" ]]; then + exit 0 +fi + +DECISIONS_DIR_REL="$(config_get decisions_dir)" + +# Locally-changed files (working-tree state included — matches rename-decision.sh). +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) + +if [[ -z "$CHANGED_FILES" ]]; then + exit 0 +fi + +while IFS=$'\t' read -r _path old_id new_id; do + [[ -z "$old_id" ]] && continue + + while IFS= read -r f; do + [[ -z "$f" || ! -f "$f" ]] && continue + # Decision files are handled inside rename-decision.sh's substitution pass. + [[ "$f" == "$DECISIONS_DIR_REL"/* ]] && continue + + # Digit-aware: only match DL-OLD when not followed by another digit, so + # renaming DL-200 does not surface bogus matches inside DL-2000. + # `|| true` keeps a no-match grep from aborting the script under set -e/pipefail. + matches=$(grep -nE "${old_id}([^0-9]|\$)" "$f" 2>/dev/null || true) + [[ -z "$matches" ]] && continue + while IFS=: read -r lineno rest; do + [[ -z "$lineno" ]] && continue + printf '%s\t%s\t%s\t%s\t%s\n' "$f" "$lineno" "$old_id" "$new_id" "$rest" + done <<< "$matches" + done <<< "$CHANGED_FILES" +done <<< "$PLAN" diff --git a/skills/dld-reindex/scripts/rename-decision.sh b/skills/dld-reindex/scripts/rename-decision.sh index bf1441c..41907ec 100755 --- a/skills/dld-reindex/scripts/rename-decision.sh +++ b/skills/dld-reindex/scripts/rename-decision.sh @@ -81,8 +81,14 @@ git mv "$INPUT_PATH" "$NEW_PATH" sed_inplace "$NEW_PATH" -e "s/^id:[[:space:]]*${OLD}\$/id: ${NEW}/" substitute_in_file "$NEW_PATH" -# 3. Determine the local change set (files added or modified vs the base ref). -CHANGED_FILES=$(git diff --name-only --diff-filter=AM "$BASE"...HEAD 2>/dev/null || true) +# 3. Determine the local change set: files added/modified/renamed vs the base ref, +# INCLUDING uncommitted working-tree state. Using `$BASE` (no `..HEAD`) lets the +# diff include working-tree changes — so when this script is called repeatedly +# during a multi-rename run, each invocation sees the renamed files produced by +# the previous calls (the new paths) instead of the stale committed paths (the +# old paths, now gone from disk). Without this, only the *last* rename's +# new-path file would have its cross-references updated to subsequent renames. +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) DECISIONS_DIR_REL="$(config_get decisions_dir)" ANNOTATION_PREFIX="$(config_get annotation_prefix)" diff --git a/tests/test_reindex.bats b/tests/test_reindex.bats index 4549791..fc414f8 100644 --- a/tests/test_reindex.bats +++ b/tests/test_reindex.bats @@ -255,6 +255,61 @@ EOF refute_output --partial "@decision(DL-002)" } +@test "rename-decision: multi-rename updates cross-refs in EARLIER renamed files" { + # Regression: when DL-205 → DL-211 runs first and DL-206 → DL-212 runs second, + # the DL-206 → DL-212 invocation must see the already-renamed DL-211.md + # (uncommitted at that point) and rewrite its DL-206 references too. + # The earlier bug computed CHANGED_FILES from committed state only, missed + # the new path, and left stale DL-206 references in DL-211.md's body. + cat > decisions/records/DL-205.md <<'EOF' +--- +id: DL-205 +title: "First" +timestamp: 2026-01-15T10:00:00Z +status: proposed +supersedes: [] +amends: [] +tags: [] +references: [] +--- + +This decision precedes DL-206. See DL-206 for the follow-up. +EOF + cat > decisions/records/DL-206.md <<'EOF' +--- +id: DL-206 +title: "Second" +timestamp: 2026-01-15T10:00:00Z +status: proposed +supersedes: [] +amends: [DL-205] +tags: [] +references: [] +--- + +Builds on DL-205. +EOF + git add -A + git commit --quiet -m "local DL-205 and DL-206" + + # Renames applied in order, mimicking how plan-renames.sh emits them. + bash "$REINDEX_DIR/rename-decision.sh" --old DL-205 --new DL-211 \ + --path decisions/records/DL-205.md --base main >/dev/null + bash "$REINDEX_DIR/rename-decision.sh" --old DL-206 --new DL-212 \ + --path decisions/records/DL-206.md --base main >/dev/null + + # DL-211.md (renamed in pass 1) must reflect the pass-2 rename in its body. + run cat decisions/records/DL-211.md + assert_output --partial "precedes DL-212" + assert_output --partial "See DL-212" + refute_output --partial "DL-206" + + # DL-212.md (renamed in pass 2) reflects the pass-1 rename in its frontmatter. + run cat decisions/records/DL-212.md + assert_output --partial "amends: [DL-211]" + assert_output --partial "Builds on DL-211" +} + @test "rename-decision: rewrites cross-refs in other local decisions" { create_decision "DL-002" "proposed" cat > decisions/records/DL-003.md <<'EOF' @@ -337,6 +392,66 @@ EOF assert_output --partial "DL-[0-9]+" } +# --- find-stale-mentions.sh -------------------------------------------------- + +@test "find-stale-mentions: surfaces bare DL-OLD mentions in non-decision changed files post-rename" { + create_decision "DL-002" "proposed" + mkdir -p src + cat > src/auth.py <<'EOF' +# @decision(DL-002) +# Span-driven batching (DL-002) groups results — see also DL-002 in the design doc. +def login(): pass +EOF + git add -A + git commit --quiet -m "local DL-002 + code" + + bash "$REINDEX_DIR/rename-decision.sh" --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md --base main >/dev/null + + PLAN=$'decisions/records/DL-002.md\tDL-002\tDL-007' + run bash -c "echo \"$PLAN\" | bash \"$REINDEX_DIR/find-stale-mentions.sh\" --base main" + assert_success + # The @decision annotation got rewritten by rename-decision.sh, so it doesn't appear. + refute_output --partial "@decision(DL-002)" + # The bare DL-002 mentions in the comment DO appear for review. + assert_output --partial "src/auth.py" + assert_output --partial "DL-002" + assert_output --partial "DL-007" + assert_output --partial "Span-driven batching" +} + +@test "find-stale-mentions: empty output when nothing to review" { + create_decision "DL-002" "proposed" + mkdir -p src + echo "# @decision(DL-002)" > src/auth.py + git add -A + git commit --quiet -m "local DL-002 + clean annotation" + + bash "$REINDEX_DIR/rename-decision.sh" --old DL-002 --new DL-007 \ + --path decisions/records/DL-002.md --base main >/dev/null + + PLAN=$'decisions/records/DL-002.md\tDL-002\tDL-007' + run bash -c "echo \"$PLAN\" | bash \"$REINDEX_DIR/find-stale-mentions.sh\" --base main" + assert_success + assert_output "" +} + +@test "find-stale-mentions: digit-aware — does not flag DL-2000 when looking for DL-200" { + create_decision "DL-200" "proposed" + mkdir -p src + echo "# unrelated DL-2000 token here" > src/auth.py + git add -A + git commit --quiet -m "local DL-200 + unrelated 2000 mention" + + bash "$REINDEX_DIR/rename-decision.sh" --old DL-200 --new DL-300 \ + --path decisions/records/DL-200.md --base main >/dev/null + + PLAN=$'decisions/records/DL-200.md\tDL-200\tDL-300' + run bash -c "echo \"$PLAN\" | bash \"$REINDEX_DIR/find-stale-mentions.sh\" --base main" + assert_success + refute_output --partial "DL-2000" +} + # --- commit-reindex.sh ------------------------------------------------------- @test "commit-reindex: squashes branch commits into a single reindex commit" { From c2f89cef202891b0b7ee8abc232ab3ef1dec331b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Mon, 18 May 2026 16:40:26 +0200 Subject: [PATCH 8/8] Address /dld-reindex review findings - list-taken-ids.sh: scope the open-PR scrape to paths under decisions/records/ so unrelated PRs touching e.g. notes/DL-007-meeting.md don't poison the taken-IDs set. - rename-decision.sh / find-stale-mentions.sh: diff against the merge-base instead of $BASE's tip so we don't conflate main's post-branch-point changes with feature's local work. Working-tree state is still included. - Add a namespaced end-to-end test. find-collisions.sh already worked for namespaces (git's pathspec recurses); the test pins that behavior down alongside the rename + rebase flow with auth/billing namespaces. - commit-reindex.sh: trap EXIT to roll HEAD back if anything between the mixed reset and the commit fails (previously the branch was left at merge-base with renames floating in the working tree, no recovery hint). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dld-reindex/scripts/commit-reindex.sh | 17 ++++ .../scripts/find-stale-mentions.sh | 7 +- .../dld-reindex/scripts/list-taken-ids.sh | 5 +- .../dld-reindex/scripts/rename-decision.sh | 20 +++-- skills/dld-reindex/scripts/commit-reindex.sh | 17 ++++ .../scripts/find-stale-mentions.sh | 7 +- skills/dld-reindex/scripts/list-taken-ids.sh | 5 +- skills/dld-reindex/scripts/rename-decision.sh | 20 +++-- tests/test_reindex.bats | 90 +++++++++++++++++++ 9 files changed, 166 insertions(+), 22 deletions(-) diff --git a/.claude/skills/dld-reindex/scripts/commit-reindex.sh b/.claude/skills/dld-reindex/scripts/commit-reindex.sh index 252700e..2301145 100755 --- a/.claude/skills/dld-reindex/scripts/commit-reindex.sh +++ b/.claude/skills/dld-reindex/scripts/commit-reindex.sh @@ -111,6 +111,22 @@ else SUBJECT="reindex local decisions: $RENAMES" fi +# Capture the original HEAD so a failure after the reset can roll back. Without +# this, an interrupted commit-reindex leaves the branch at merge-base with the +# renames floating in the working tree — confusing to recover from. +ORIG_HEAD=$(git rev-parse HEAD) +HEAD_RESTORED=0 +restore_head_on_failure() { + local rc=$? + if [[ "$rc" -ne 0 && "$HEAD_RESTORED" -eq 0 ]]; then + if [[ "$(git rev-parse HEAD)" != "$ORIG_HEAD" ]]; then + echo "[commit-reindex] failed (exit $rc) — restoring HEAD to $ORIG_HEAD" >&2 + git reset --quiet --soft "$ORIG_HEAD" || true + fi + fi +} +trap restore_head_on_failure EXIT + # Mixed reset to merge-base — moves HEAD, clears the index, leaves the working tree alone. git reset --quiet "$MERGE_BASE" @@ -156,5 +172,6 @@ ${RENAME_LIST% fi git commit --quiet -m "$FULL_MSG" +HEAD_RESTORED=1 # past the point of no return — don't roll back on later non-zero exits NEW_HEAD=$(git rev-parse --short HEAD) echo "Created reindex commit $NEW_HEAD on top of $(git rev-parse --short "$MERGE_BASE")" diff --git a/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh b/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh index cd55783..f062866 100755 --- a/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh +++ b/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh @@ -47,8 +47,11 @@ fi DECISIONS_DIR_REL="$(config_get decisions_dir)" -# Locally-changed files (working-tree state included — matches rename-decision.sh). -CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) +# Locally-changed files (working-tree state included; diffed against the +# merge-base for the same reason rename-decision.sh does — avoid conflating +# main's post-branch-point changes with feature's local work). +MERGE_BASE=$(git merge-base "$BASE" HEAD) +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$MERGE_BASE" 2>/dev/null || true) if [[ -z "$CHANGED_FILES" ]]; then exit 0 diff --git a/.claude/skills/dld-reindex/scripts/list-taken-ids.sh b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh index 0b86981..f666947 100755 --- a/.claude/skills/dld-reindex/scripts/list-taken-ids.sh +++ b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh @@ -46,10 +46,13 @@ PR_BASE="${BASE#origin/}" git -C "$PROJECT_ROOT" ls-tree -r --name-only "$BASE" -- "$RECORDS_DIR_REL" 2>/dev/null \ | grep -oE 'DL-[0-9]+' || true - # IDs in files touched by open PRs targeting this base + # IDs in files touched by open PRs targeting this base. Scope to paths under + # the records dir so an unrelated PR touching e.g. notes/DL-007-meeting.md + # doesn't poison the taken set. if [[ -z "$SKIP_REASON" ]]; then gh pr list --state open --base "$PR_BASE" --json files --limit 100 \ --jq '.[].files[].path' 2>/dev/null \ + | grep -E "^${RECORDS_DIR_REL}/" \ | grep -oE 'DL-[0-9]+' || true fi } | sort -u diff --git a/.claude/skills/dld-reindex/scripts/rename-decision.sh b/.claude/skills/dld-reindex/scripts/rename-decision.sh index 41907ec..c51b001 100755 --- a/.claude/skills/dld-reindex/scripts/rename-decision.sh +++ b/.claude/skills/dld-reindex/scripts/rename-decision.sh @@ -81,14 +81,18 @@ git mv "$INPUT_PATH" "$NEW_PATH" sed_inplace "$NEW_PATH" -e "s/^id:[[:space:]]*${OLD}\$/id: ${NEW}/" substitute_in_file "$NEW_PATH" -# 3. Determine the local change set: files added/modified/renamed vs the base ref, -# INCLUDING uncommitted working-tree state. Using `$BASE` (no `..HEAD`) lets the -# diff include working-tree changes — so when this script is called repeatedly -# during a multi-rename run, each invocation sees the renamed files produced by -# the previous calls (the new paths) instead of the stale committed paths (the -# old paths, now gone from disk). Without this, only the *last* rename's -# new-path file would have its cross-references updated to subsequent renames. -CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) +# 3. Determine the local change set: files added/modified/renamed since the +# merge-base with $BASE, INCLUDING uncommitted working-tree state. Diffing +# against the merge-base (rather than $BASE's tip) avoids conflating main's +# post-branch-point changes with feature's local work. Plain `git diff ` +# (no `..HEAD`) lets the diff include working-tree changes — so when this +# script is called repeatedly during a multi-rename run, each invocation sees +# the renamed files produced by the previous calls (the new paths) instead of +# the stale committed paths (the old paths, now gone from disk). Without this, +# only the *last* rename's new-path file would have its cross-references +# updated to subsequent renames. +MERGE_BASE=$(git merge-base "$BASE" HEAD) +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$MERGE_BASE" 2>/dev/null || true) DECISIONS_DIR_REL="$(config_get decisions_dir)" ANNOTATION_PREFIX="$(config_get annotation_prefix)" diff --git a/skills/dld-reindex/scripts/commit-reindex.sh b/skills/dld-reindex/scripts/commit-reindex.sh index 252700e..2301145 100755 --- a/skills/dld-reindex/scripts/commit-reindex.sh +++ b/skills/dld-reindex/scripts/commit-reindex.sh @@ -111,6 +111,22 @@ else SUBJECT="reindex local decisions: $RENAMES" fi +# Capture the original HEAD so a failure after the reset can roll back. Without +# this, an interrupted commit-reindex leaves the branch at merge-base with the +# renames floating in the working tree — confusing to recover from. +ORIG_HEAD=$(git rev-parse HEAD) +HEAD_RESTORED=0 +restore_head_on_failure() { + local rc=$? + if [[ "$rc" -ne 0 && "$HEAD_RESTORED" -eq 0 ]]; then + if [[ "$(git rev-parse HEAD)" != "$ORIG_HEAD" ]]; then + echo "[commit-reindex] failed (exit $rc) — restoring HEAD to $ORIG_HEAD" >&2 + git reset --quiet --soft "$ORIG_HEAD" || true + fi + fi +} +trap restore_head_on_failure EXIT + # Mixed reset to merge-base — moves HEAD, clears the index, leaves the working tree alone. git reset --quiet "$MERGE_BASE" @@ -156,5 +172,6 @@ ${RENAME_LIST% fi git commit --quiet -m "$FULL_MSG" +HEAD_RESTORED=1 # past the point of no return — don't roll back on later non-zero exits NEW_HEAD=$(git rev-parse --short HEAD) echo "Created reindex commit $NEW_HEAD on top of $(git rev-parse --short "$MERGE_BASE")" diff --git a/skills/dld-reindex/scripts/find-stale-mentions.sh b/skills/dld-reindex/scripts/find-stale-mentions.sh index cd55783..f062866 100755 --- a/skills/dld-reindex/scripts/find-stale-mentions.sh +++ b/skills/dld-reindex/scripts/find-stale-mentions.sh @@ -47,8 +47,11 @@ fi DECISIONS_DIR_REL="$(config_get decisions_dir)" -# Locally-changed files (working-tree state included — matches rename-decision.sh). -CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) +# Locally-changed files (working-tree state included; diffed against the +# merge-base for the same reason rename-decision.sh does — avoid conflating +# main's post-branch-point changes with feature's local work). +MERGE_BASE=$(git merge-base "$BASE" HEAD) +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$MERGE_BASE" 2>/dev/null || true) if [[ -z "$CHANGED_FILES" ]]; then exit 0 diff --git a/skills/dld-reindex/scripts/list-taken-ids.sh b/skills/dld-reindex/scripts/list-taken-ids.sh index 0b86981..f666947 100755 --- a/skills/dld-reindex/scripts/list-taken-ids.sh +++ b/skills/dld-reindex/scripts/list-taken-ids.sh @@ -46,10 +46,13 @@ PR_BASE="${BASE#origin/}" git -C "$PROJECT_ROOT" ls-tree -r --name-only "$BASE" -- "$RECORDS_DIR_REL" 2>/dev/null \ | grep -oE 'DL-[0-9]+' || true - # IDs in files touched by open PRs targeting this base + # IDs in files touched by open PRs targeting this base. Scope to paths under + # the records dir so an unrelated PR touching e.g. notes/DL-007-meeting.md + # doesn't poison the taken set. if [[ -z "$SKIP_REASON" ]]; then gh pr list --state open --base "$PR_BASE" --json files --limit 100 \ --jq '.[].files[].path' 2>/dev/null \ + | grep -E "^${RECORDS_DIR_REL}/" \ | grep -oE 'DL-[0-9]+' || true fi } | sort -u diff --git a/skills/dld-reindex/scripts/rename-decision.sh b/skills/dld-reindex/scripts/rename-decision.sh index 41907ec..c51b001 100755 --- a/skills/dld-reindex/scripts/rename-decision.sh +++ b/skills/dld-reindex/scripts/rename-decision.sh @@ -81,14 +81,18 @@ git mv "$INPUT_PATH" "$NEW_PATH" sed_inplace "$NEW_PATH" -e "s/^id:[[:space:]]*${OLD}\$/id: ${NEW}/" substitute_in_file "$NEW_PATH" -# 3. Determine the local change set: files added/modified/renamed vs the base ref, -# INCLUDING uncommitted working-tree state. Using `$BASE` (no `..HEAD`) lets the -# diff include working-tree changes — so when this script is called repeatedly -# during a multi-rename run, each invocation sees the renamed files produced by -# the previous calls (the new paths) instead of the stale committed paths (the -# old paths, now gone from disk). Without this, only the *last* rename's -# new-path file would have its cross-references updated to subsequent renames. -CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$BASE" 2>/dev/null || true) +# 3. Determine the local change set: files added/modified/renamed since the +# merge-base with $BASE, INCLUDING uncommitted working-tree state. Diffing +# against the merge-base (rather than $BASE's tip) avoids conflating main's +# post-branch-point changes with feature's local work. Plain `git diff ` +# (no `..HEAD`) lets the diff include working-tree changes — so when this +# script is called repeatedly during a multi-rename run, each invocation sees +# the renamed files produced by the previous calls (the new paths) instead of +# the stale committed paths (the old paths, now gone from disk). Without this, +# only the *last* rename's new-path file would have its cross-references +# updated to subsequent renames. +MERGE_BASE=$(git merge-base "$BASE" HEAD) +CHANGED_FILES=$(git diff --find-renames --name-only --diff-filter=AMR "$MERGE_BASE" 2>/dev/null || true) DECISIONS_DIR_REL="$(config_get decisions_dir)" ANNOTATION_PREFIX="$(config_get annotation_prefix)" diff --git a/tests/test_reindex.bats b/tests/test_reindex.bats index fc414f8..ddcb89f 100644 --- a/tests/test_reindex.bats +++ b/tests/test_reindex.bats @@ -556,6 +556,96 @@ EOF assert_output --partial "no rename plan" } +@test "commit-reindex: restores HEAD if the squash fails after the reset" { + # Install a pre-commit hook that always rejects, so commit-reindex.sh fails + # AFTER the mixed reset has already moved HEAD. The trap should roll HEAD + # back to its pre-run position. + git checkout main --quiet + create_decision "DL-002" "accepted" + git add -A + git commit --quiet -m "land DL-002 on main" + git checkout feature --quiet + + create_decision "DL-002" "proposed" + git add -A + git commit --quiet -m "feature: draft DL-002" + + cat > .git/hooks/pre-commit <<'EOF' +#!/bin/sh +exit 1 +EOF + chmod +x .git/hooks/pre-commit + + PLAN=$(bash "$REINDEX_DIR/plan-renames.sh" --base main 2>/dev/null) + echo "$PLAN" | while IFS=$'\t' read -r path old new; do + bash "$REINDEX_DIR/rename-decision.sh" --old "$old" --new "$new" --path "$path" --base main + done >/dev/null + + BEFORE_HEAD=$(git rev-parse HEAD) + run bash -c "echo \"$PLAN\" | bash \"$REINDEX_DIR/commit-reindex.sh\" --base main" + assert_failure + AFTER_HEAD=$(git rev-parse HEAD) + [[ "$BEFORE_HEAD" == "$AFTER_HEAD" ]] || { + echo "HEAD drifted: $BEFORE_HEAD -> $AFTER_HEAD" >&2 + return 1 + } +} + + + +# --- namespaced project ------------------------------------------------------ + +@test "namespaced project: end-to-end reindex preserves namespace path" { + # Replace the flat-mode setup with a namespaced one. + teardown_project + setup_namespaced_project + + # Seed main with one namespaced decision so origin/main has known state. + create_decision "DL-001" "accepted" "auth" + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null + git add -A + git commit --quiet -m "seed main" + git branch -M main + git checkout -b feature --quiet + + # main lands DL-002 (in billing namespace) after feature branches. + git checkout main --quiet + create_decision "DL-002" "accepted" "billing" + bash "$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" >/dev/null + git add -A + git commit --quiet -m "land DL-002 on main" + git checkout feature --quiet + + # feature drafts DL-002 in the auth namespace (collides by ID, different path). + create_decision "DL-002" "proposed" "auth" + git add -A + git commit --quiet -m "feature: draft DL-002 in auth namespace" + + # find-collisions picks up the namespaced addition. + run bash -c "bash \"$REINDEX_DIR/find-collisions.sh\" --base main 2>/dev/null" + assert_success + assert_output --partial $'decisions/records/auth/DL-002.md\tDL-002' + + # Full flow. + PLAN=$(bash "$REINDEX_DIR/plan-renames.sh" --base main 2>/dev/null) + echo "$PLAN" | while IFS=$'\t' read -r path old new; do + bash "$REINDEX_DIR/rename-decision.sh" --old "$old" --new "$new" --path "$path" --base main + done >/dev/null + echo "$PLAN" | bash "$REINDEX_DIR/commit-reindex.sh" --base main >/dev/null + + # The renamed file lives in the same namespace dir as the original. + [[ ! -f decisions/records/auth/DL-002.md ]] + [[ -f decisions/records/auth/DL-003.md ]] + + # Rebase clean. + run git rebase main + assert_success + + # Post-rebase: main's billing/DL-002 and feature's auth/DL-003 both exist. + [[ -f decisions/records/billing/DL-002.md ]] + [[ -f decisions/records/auth/DL-003.md ]] +} + # --- end-to-end -------------------------------------------------------------- @test "end-to-end: post-reindex branch rebases cleanly onto main" {