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 new file mode 100644 index 0000000..328d072 --- /dev/null +++ b/.claude/skills/dld-reindex/SKILL.md @@ -0,0 +1,182 @@ +--- +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, 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 and produces a branch state that rebases cleanly. + +**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 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. + +## 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/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/find-stale-mentions.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. Fetch the latest base state: + ```bash + git fetch origin + ``` + +## 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" +``` + +Output is tab-separated, one rename per line: + +``` +\t\t +``` + +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`. **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 3: Get explicit consent for the history rewrite + +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: + +- `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. +- 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`. + +**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`: + +```bash +echo "$PLAN" | bash .claude/skills/dld-reindex/scripts/commit-reindex.sh --base "$BASE" +``` + +This: + +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. + +**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 7: Push (if the user chose force-push) + +If step 3's answer was "Rewrite and force-push": + +```bash +if git rev-parse --verify --quiet "@{upstream}" >/dev/null; then + git push --force-with-lease +else + git push -u origin HEAD +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 8: Report + +Print: + +- The renames table. +- The stderr note from step 2 if `gh` was skipped. +- The number of commits squashed. +- The next steps: + +> 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.** 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..2301145 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/commit-reindex.sh @@ -0,0 +1,177 @@ +#!/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 + +# 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" + +# 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" +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-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/find-stale-mentions.sh b/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh new file mode 100755 index 0000000..f062866 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/find-stale-mentions.sh @@ -0,0 +1,78 @@ +#!/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; 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 +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/list-taken-ids.sh b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh new file mode 100755 index 0000000..f666947 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh @@ -0,0 +1,62 @@ +#!/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. 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 + +if [[ -n "$SKIP_REASON" ]]; then + echo "[dld-reindex] open PRs not scanned: $SKIP_REASON" >&2 +fi 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/.claude/skills/dld-reindex/scripts/rename-decision.sh b/.claude/skills/dld-reindex/scripts/rename-decision.sh new file mode 100755 index 0000000..c51b001 --- /dev/null +++ b/.claude/skills/dld-reindex/scripts/rename-decision.sh @@ -0,0 +1,119 @@ +#!/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/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)" + +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/.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/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 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-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 new file mode 100644 index 0000000..45d08ab --- /dev/null +++ b/skills/dld-reindex/SKILL.md @@ -0,0 +1,182 @@ +--- +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, 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 and produces a branch state that rebases cleanly. + +**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 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. + +## Script Paths + +Shared scripts: +``` +../dld-common/scripts/common.sh +../dld-common/scripts/regenerate-index.sh +``` + +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/find-stale-mentions.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. Fetch the latest base state: + ```bash + git fetch origin + ``` + +## 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" +``` + +Output is tab-separated, one rename per line: + +``` +\t\t +``` + +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`. **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 3: Get explicit consent for the history rewrite + +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: + +- `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. +- 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`. + +**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`: + +```bash +echo "$PLAN" | bash scripts/commit-reindex.sh --base "$BASE" +``` + +This: + +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. + +**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 7: Push (if the user chose force-push) + +If step 3's answer was "Rewrite and force-push": + +```bash +if git rev-parse --verify --quiet "@{upstream}" >/dev/null; then + git push --force-with-lease +else + git push -u origin HEAD +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 8: Report + +Print: + +- The renames table. +- The stderr note from step 2 if `gh` was skipped. +- The number of commits squashed. +- The next steps: + +> 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.** 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..2301145 --- /dev/null +++ b/skills/dld-reindex/scripts/commit-reindex.sh @@ -0,0 +1,177 @@ +#!/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 + +# 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" + +# 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" +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-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/find-stale-mentions.sh b/skills/dld-reindex/scripts/find-stale-mentions.sh new file mode 100755 index 0000000..f062866 --- /dev/null +++ b/skills/dld-reindex/scripts/find-stale-mentions.sh @@ -0,0 +1,78 @@ +#!/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; 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 +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/list-taken-ids.sh b/skills/dld-reindex/scripts/list-taken-ids.sh new file mode 100755 index 0000000..f666947 --- /dev/null +++ b/skills/dld-reindex/scripts/list-taken-ids.sh @@ -0,0 +1,62 @@ +#!/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. 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 + +if [[ -n "$SKIP_REASON" ]]; then + echo "[dld-reindex] open PRs not scanned: $SKIP_REASON" >&2 +fi 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/skills/dld-reindex/scripts/rename-decision.sh b/skills/dld-reindex/scripts/rename-decision.sh new file mode 100755 index 0000000..c51b001 --- /dev/null +++ b/skills/dld-reindex/scripts/rename-decision.sh @@ -0,0 +1,119 @@ +#!/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/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)" + +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/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_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" diff --git a/tests/test_reindex.bats b/tests/test_reindex.bats new file mode 100644 index 0000000..ddcb89f --- /dev/null +++ b/tests/test_reindex.bats @@ -0,0 +1,709 @@ +#!/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. + # 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 + 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" +} + +# --- 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" { + 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" { + 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: 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' +--- +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]+" +} + +# --- 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" { + # 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" +} + +@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" { + # 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" +} 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" } } }