From 728671adb8be1512ad795f2b0cd029e1aa5de380 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 02:20:09 +0000 Subject: [PATCH 1/6] feat(repo-hygiene): capture every branch tip and require it before any deletion batch git-branch-audit.sh reports a Tip: field for every branch and writes the tips, with tier, PR, upstream, ahead/behind and a timestamp, to a sealed capture under the main checkout's .git/repo-hygiene/branch-tips/, printed as TipCapture:. git-branch-delete.sh is the only sanctioned deletion path: it refuses the whole batch without a capture, a captured tip per branch, and an unmoved tip; in --apply it pins each tip under refs/repo-hygiene/deleted/, appends the deletion to a ledger, and only then deletes. The test restores deleted branches from the capture alone after gc --prune=now. Closes #3853 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --- .../repo-hygiene/.claude-plugin/plugin.json | 2 +- plugins/repo-hygiene/CHANGELOG.md | 27 ++ plugins/repo-hygiene/skills/clean/SKILL.md | 2 + .../skills/clean/context/action-router.md | 2 +- .../clean/context/git-branch-cleanup.md | 45 ++- .../skills/clean/scripts/git-branch-audit.sh | 146 +++++++- .../clean/scripts/git-branch-audit.test.sh | 56 +++ .../skills/clean/scripts/git-branch-delete.sh | 352 ++++++++++++++++++ .../clean/scripts/git-branch-delete.test.sh | 282 ++++++++++++++ .../skills/clean/scripts/lib/clean-common.sh | 19 + 10 files changed, 905 insertions(+), 28 deletions(-) create mode 100755 plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh create mode 100644 plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh diff --git a/plugins/repo-hygiene/.claude-plugin/plugin.json b/plugins/repo-hygiene/.claude-plugin/plugin.json index 0b13a3c81e..b8a4be3ffb 100644 --- a/plugins/repo-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "repo-hygiene", - "version": "0.10.34", + "version": "0.10.35", "description": "Repo hygiene action-router: /repo-hygiene:clean sweeps reclaimable caches, build artifacts, and stale git metadata, and can realign the working tree to a fresh-pull state \u2014 dry-run-first, with destructive tiers gated behind explicit confirmation and a session-scoped destructive-command guard. Ecosystem targets are detected at runtime; secrets, runtime dependencies, and skill data are preserved by default.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-hygiene/CHANGELOG.md b/plugins/repo-hygiene/CHANGELOG.md index a04f4b3d15..3f2caba70c 100644 --- a/plugins/repo-hygiene/CHANGELOG.md +++ b/plugins/repo-hygiene/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to the `repo-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.35] + +### Added + +- `clean`: `git-branch-audit.sh` reports each branch's tip commit as a `Tip:` field, for every + branch regardless of verdict, and writes the tips with tier, PR state, upstream, ahead/behind and + a timestamp to a durable capture under the main checkout's `.git/repo-hygiene/branch-tips/`, + printing its path as `TipCapture:`. The file is written to a `.part` and sealed only when the + row count matches; any failure prints `TipCaptureError:` and no path, so a partial capture can + never pass for a complete one. `--capture-file PATH` overrides the location. +- `clean`: `git-branch-delete.sh`, the only sanctioned branch-deletion path for the git tier. It + takes the audit's capture and refuses the whole batch, deleting nothing, when the capture is + missing, a branch has no captured tip, a captured tip no longer matches the branch, or a branch + is PROTECTED, WORKTREE, or an unforced REVIEW. In `--apply` it re-verifies the tip, pins it under + `refs/repo-hygiene/deleted/` so `gc` cannot prune the commits the record points at, + appends the deletion to `.deleted.tsv`, and only then deletes; a failure in any of + those steps aborts the batch before that branch is touched. Every `Deleted:` line and the + closing `Restore:` line carry the restore command. The test deletes branches through the script, + runs `gc --prune=now`, and restores them from the capture alone. Closes #3853, the first half of + #3346 gap G1. + +### Changed + +- `clean`: §4.7 of `context/git-branch-cleanup.md` routes deletion through the script instead of + a bare `git branch -d`/`-D`, documents the capture path convention and the ledger, and gains a + §4.8 with the recovery steps; SKILL.md §4.2 states the precondition. + ## [0.10.34] ### Fixed diff --git a/plugins/repo-hygiene/skills/clean/SKILL.md b/plugins/repo-hygiene/skills/clean/SKILL.md index b2ce928a6a..b73bf609bc 100644 --- a/plugins/repo-hygiene/skills/clean/SKILL.md +++ b/plugins/repo-hygiene/skills/clean/SKILL.md @@ -169,6 +169,8 @@ Both selective mutating tiers pay the filesystem walk **once**. `--dry-run` writ `${CLAUDE_SKILL_DIR}/scripts/git-branch-audit.sh`. Deletion via the [confirmation gate](#confirmation-gate) per [context/git-branch-cleanup.md](context/git-branch-cleanup.md). **Read the PR-map status line first.** `PRDataUnavailable:` means squash-merge detection did not run at all, and `PRDataTruncated:` means the map may be short. Under either line a branch that actually landed can be classified `REVIEW` or worse, so surface the line in the confirmation gate and do not present a `SAFE`/`REVIEW` split as complete evidence. Branches in the `WORKTREE` tier are checked out in a linked worktree: never offer them for `git branch -d`. Route the user to the worktree-management tool to clean up the worktree first. Branches carrying `no upstream, M commits not on origin/` are never-pushed local work. Surface the count and confirm before any deletion. +**Tip capture is a precondition for deletion.** The audit prints every branch's `Tip:` and writes them all to a durable capture, `TipCapture: ` (under the main checkout's `.git/repo-hygiene/branch-tips/`). Deletion goes only through `${CLAUDE_SKILL_DIR}/scripts/git-branch-delete.sh --capture `, dry-run first, `--apply` after the [confirmation gate](#confirmation-gate); never a bare `git branch -d`/`-D`. The script refuses the whole batch when the capture is missing, a branch has no captured tip, or a tip moved since the audit; re-run the audit rather than deleting against a stale identifier. A `TipCaptureError:` line means no deletion can proceed from that run. Relay the script's `Restore: git branch ` line to the user after every batch. Detail and recovery steps: [context/git-branch-cleanup.md](context/git-branch-cleanup.md) §4.7 and §4.8. + #### 4.3 Stash audit `${CLAUDE_SKILL_DIR}/scripts/git-stash-audit.sh`. Read-only per-stash facts (age, source branch, diffstat, PR/merge signal, advisory). **Never drops a stash.** Present the list and, for each stash, ask the user keep-or-drop ([Confirmation gate](#confirmation-gate)); a `possibly superseded` / `likely superseded` advisory is a hint to raise first, never an autonomous drop. Dedup a fleet sweep by the `StashStore:` key (linked worktrees share one stash ref). When the resolved action is `stash`, run only this step. **Read the PR-map status line first.** `PRDataUnavailable:` means the PR/merge signal did not run at all, and `PRDataTruncated:` means the map may be short. Under either line a stash whose source branch actually landed can carry no `superseded` advisory, so surface the line in the confirmation gate and do not present the advisories as complete evidence for a drop. diff --git a/plugins/repo-hygiene/skills/clean/context/action-router.md b/plugins/repo-hygiene/skills/clean/context/action-router.md index 829e96024a..04540ccd5d 100644 --- a/plugins/repo-hygiene/skills/clean/context/action-router.md +++ b/plugins/repo-hygiene/skills/clean/context/action-router.md @@ -61,7 +61,7 @@ Default when still unsure after one question: **`scan`** (safest). | --- | --- | --- | | `scan` | Run `scan.sh` | None | | `caches`, `build`, `all` | `preflight.sh` + tier scripts `--dry-run` (writes a manifest; emits `Manifest:` + `Summary: planned=N bytes=K`) | [Confirmation gate](../SKILL.md#confirmation-gate) when preflight non-empty OR before `--apply` — surface the `bytes` reclaimable total; apply the same manifest (`--apply --manifest `), which emits `Summary: removed=N failed=M bytes=K` and exits non-zero on failure | -| `git` | `git-prune.sh --dry-run`, `git-branch-audit.sh`, `git-stash-audit.sh` | Before `--apply` prune; before any branch deletion; per-stash keep/drop | +| `git` | `git-prune.sh --dry-run`, `git-branch-audit.sh` (writes the `TipCapture:` file), `git-branch-delete.sh --capture --dry-run`, `git-stash-audit.sh` | Before `--apply` prune; before any branch deletion (which runs only through `git-branch-delete.sh --apply` against the audit's capture); per-stash keep/drop | | `stash` | `git-stash-audit.sh` (read-only) | Per-stash keep/drop — **never** auto-dropped, even a `superseded` advisory | | `tree` | `git-tree-reset.sh --dry-run` (always) | **Mandatory** [confirmation gate](../SKILL.md#confirmation-gate) before `--apply` (surface `PreserveDeps`/`PreserveSecrets`/`AheadCount`); a non-zero `AheadCount` or exit 4 needs explicit unpushed-loss confirmation before `--allow-unpushed`; `--include-secrets` is UNRECOVERABLE — confirm separately; never autonomous | | `tree-batch` | `git-tree-reset-batch.sh --dry-run` (always) | **Mandatory** single batch-wide [confirmation gate](../SKILL.md#confirmation-gate) before `--apply` (surface the per-repo `Outcome`/`Reason`, `Summary`, and `UnmatchedSkip:`); one gate for the whole batch, never per repo; `--include-dirty` re-enables the data-loss vector — confirm separately naming the dirty repos, like `--include-secrets`; never autonomous. Detail: [git-tree-reset-batch.md](git-tree-reset-batch.md) | diff --git a/plugins/repo-hygiene/skills/clean/context/git-branch-cleanup.md b/plugins/repo-hygiene/skills/clean/context/git-branch-cleanup.md index 4bbf9f4573..b9afbb50c0 100644 --- a/plugins/repo-hygiene/skills/clean/context/git-branch-cleanup.md +++ b/plugins/repo-hygiene/skills/clean/context/git-branch-cleanup.md @@ -10,10 +10,12 @@ Run the branch-audit script — do not reimplement collection inline: bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-branch-audit.sh ``` -**Output contract**: a leading PR-map status line, exactly one of `PRCount: ` or `PRDataUnavailable: `, optionally followed by `PRDataTruncated: `; then per branch `Branch:`, `Tier:`, `Age days:`, `PR:`, `Unpushed:`, `Reason:`; trailing `Summary: protected=… worktree=… safe=… likely-safe=… review=…`. +**Output contract**: a leading PR-map status line, exactly one of `PRCount: ` or `PRDataUnavailable: `, optionally followed by `PRDataTruncated: `; then per branch `Branch:`, `Tip:`, `Tier:`, `Age days:`, `PR:`, `Unpushed:`, `Reason:`; then exactly one of `TipCapture: ` or `TipCaptureError: `; trailing `Summary: protected=… worktree=… safe=… likely-safe=… review=…`. **PR-map status line**, the trustworthiness of every PR-derived verdict below. `PRCount: 0` is a repository with no pull requests, which is a real and complete answer. `PRDataUnavailable:` is a repository whose pull requests could not be read at all (no `gh`, no `jq`, unauthenticated, no GitHub remote, unparsable output, or the map file could not be created): squash-merge detection never ran, priority 5 cannot fire, and a landed branch falls through to REVIEW. `PRDataTruncated:` means the returned count equalled the requested cap and rows may have been discarded, with the same effect for whichever branches are missing from the map. Surface either line in the confirmation gate; underneath one, the tier split is not complete evidence. +**`Tip:` line and the tip capture.** Every branch carries its tip commit id as its own field, whatever its verdict: a verdict can be wrong in either direction, and the tip is what makes a wrongly deleted branch restorable. The same facts are written to a durable TSV, the **tip capture**, and its path is printed as `TipCapture:`. Path convention: `/repo-hygiene/branch-tips/-.tsv`, i.e. the main checkout's `.git/repo-hygiene/branch-tips/` even when the audit ran in a linked worktree (`--capture-file PATH` overrides). Columns: `branch`, `tip`, `tier`, `pr`, `upstream`, `ahead`, `behind`, `not_on_default`, `captured_at`, with header lines naming the repository, its common dir, the default branch, and the restore command. The file is sealed only when every row landed; otherwise the audit prints `TipCaptureError:` and no path. **A `TipCaptureError:` means no deletion can proceed from this run**: fix the cause (or pass `--capture-file` to a writable location) and re-run the audit. Capture files are small and are never removed by this skill; delete old ones by hand if they accumulate. + **`Unpushed:` line** — commits at risk of loss. With an upstream: `N ahead of `. With no upstream: `no upstream, M commits not on origin/` (or `no upstream (no origin/ to compare)` when the default branch is unfetched). Never-pushed local work is invisible to `@{upstream}`-based ahead reporting, so this line is the only signal that a no-upstream branch carries unmerged commits — surface it before offering any deletion. **Default branch resolution** (inside script): `origin/HEAD` symbolic ref → `gh repo view --json defaultBranchRef` → `main`. @@ -74,16 +76,43 @@ Map script output to a table: If SAFE or LIKELY-SAFE branches exist, present options via the [confirmation gate](../SKILL.md#confirmation-gate): -- "Delete all SAFE branches" — `git branch -D` for squash-merged (PR MERGED), `git branch -d` for others (safe delete, refuses if unmerged). Squash merge changes SHA so `-d` refuses even when PR is merged — `-D` is safe because PR merge is already confirmed via `gh pr list` -- "Delete SAFE + LIKELY-SAFE" — same as above for SAFE; `git branch -D` for LIKELY-SAFE (force delete — upstream gone) +- "Delete all SAFE branches" +- "Delete SAFE + LIKELY-SAFE" - "Skip (audit only)" — no deletion -For each deletion, report result: +**Every deletion goes through the deletion script, never a bare `git branch -d`/`-D`.** The script is the enforcement point for tip capture: it refuses the whole batch (exit 3, nothing deleted) unless it is given the audit's `TipCapture:` file, every branch in the batch has a row in it, and every captured tip still equals the branch's current tip. Dry-run first, with exactly the set the user is about to confirm: +```bash +bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-branch-delete.sh --capture --dry-run ... ``` -Deleted: feat/old-thing (was SAFE — PR #123 merged) -Deleted: refactor/x (was LIKELY-SAFE — upstream gone) -Failed: some-branch — has unmerged changes (use -D to force) + +Show the `Planned:` lines (branch, tip, tier, and whether `-d` or `-D` will be used) in the confirmation. After the user confirms that exact set: + +```bash +CLEAN_GUARD_ACK=1 bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-branch-delete.sh --capture --apply ... ``` -After deletion, run `bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-prune.sh --apply` to prune orphaned worktree metadata and compact loose objects. +Per branch the script, in this order, re-checks the tip against the capture, pins it under `refs/repo-hygiene/deleted/` (so a later `gc` cannot prune the commits the record points at), appends the deletion to the ledger `.deleted.tsv`, and only then deletes: `-d` for SAFE by ancestry, `-D` for SAFE with a merged PR (a squash merge changes the SHA, so `-d` would refuse a branch whose merge `gh pr list` already confirmed) and for LIKELY-SAFE. A failure in any step before the delete aborts the batch there (exit 1): branches already deleted keep their pin and ledger row, the failing branch and everything after it are untouched, and the `Summary:` line counts each. A refusal (a branch whose tip moved since the audit, a branch with no captured tip, a foreign or unreadable capture) stops the batch before the first deletion; re-run the audit for a fresh capture rather than deleting against a stale identifier. REVIEW branches need `--force-review`, and only after the user has confirmed the loss named in their `Unpushed:`/`Reason:` lines; PROTECTED and WORKTREE branches are never deletable here. + +The script reports one line per branch: + +``` +Deleted: feat/old-thing (was SAFE) restore: git branch feat/old-thing +Failed: some-branch (error: the branch 'some-branch' is not fully merged) +Summary: planned=N refused=0 deleted=N failed=M +Restore: git branch (tips in and ; pinned under refs/repo-hygiene/deleted/) +``` + +Relay the `Restore:` line to the user verbatim after every deletion batch. + +After deletion, run `bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/git-prune.sh --apply` to prune orphaned worktree metadata and compact loose objects. The pinned refs keep every deleted tip reachable through that step. + +## 4.8 Restore a deleted branch + +Everything needed is in the repository's main `.git`, so recovery needs no memory of how the run was invoked: + +1. Find the tip. Newest capture and ledger: `ls -t .git/repo-hygiene/branch-tips/` (the ledger `.deleted.tsv` lists only branches actually deleted; the capture `.tsv` lists every branch the audit saw, deleted or not). Or read the pin directly: `git rev-parse refs/repo-hygiene/deleted/`. +2. Recreate the branch: `git branch ` (or `git branch refs/repo-hygiene/deleted/`). +3. Optionally drop the pin once the branch is back or is no longer wanted: `git update-ref -d refs/repo-hygiene/deleted/`. Pins are never removed by this skill; `git for-each-ref refs/repo-hygiene/deleted/` lists them. A pin keeps its commits out of `gc`, so dropping pins is how that space is eventually reclaimed. + +A branch deleted, recreated under the same name, and deleted again overwrites its pin; the earlier tip stays in the earlier capture and ledger but is no longer protected from `gc`. diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh b/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh index 8909bd5f92..baf07cb6a0 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh @@ -3,12 +3,25 @@ # Branch audit facts for the clean git branch cleanup. No deletion. # # Output: PR-map status (PRCount, or PRDataUnavailable; PRDataTruncated when the -# lookup hit its cap); then per branch Branch, Tier, Age days, PR, Unpushed, -# Reason; Summary line. A missing map is NOT the same as a repo with no PRs, and -# the two are distinguishable here on purpose: PR state is what detects a -# squash merge, so without it a landed branch reads as unmerged. -# Exit: 0. -# Omit -e/-o pipefail: script always exits 0; sub-commands are best-effort (gh may be absent). +# lookup hit its cap); then per branch Branch, Tip, Tier, Age days, PR, Unpushed, +# Reason; then TipCapture (or TipCaptureError); Summary line. A missing map is +# NOT the same as a repo with no PRs, and the two are distinguishable here on +# purpose: PR state is what detects a squash merge, so without it a landed +# branch reads as unmerged. +# Exit: 0 (2 on a usage error). +# Omit -e/-o pipefail: script always exits 0 on a successful run; sub-commands +# are best-effort (gh may be absent). +# +# TIP CAPTURE. Every branch's tip commit is written, together with its verdict, +# upstream and ahead/behind counts, to a durable TSV under the repository's +# common git dir (`.git/repo-hygiene/branch-tips/-.tsv`), and +# the path is printed as `TipCapture: `. That file is the precondition +# git-branch-delete.sh demands before it deletes anything: a deleted branch is +# restorable only from its tip, and the tip must be recorded BEFORE the delete, +# not remembered from a transcript. The capture is written to a `.part` file and +# renamed into place only when every row landed; any failure (no writable +# location, a short write) yields `TipCaptureError:` instead of a path, so a +# partial capture can never present itself as a complete one. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -17,23 +30,46 @@ source "$SCRIPT_DIR/lib/clean-common.sh" usage() { cat <<'EOF' -git-branch-audit.sh — emit branch audit facts for the clean git tier. +git-branch-audit.sh - emit branch audit facts for the clean git tier. Usage: - git-branch-audit.sh + git-branch-audit.sh [--capture-file PATH] git-branch-audit.sh --help -Does NOT delete branches. Exit: 0. + --capture-file PATH write the branch-tip capture to PATH instead of the + default /repo-hygiene/branch-tips/-.tsv + +Per branch: Branch, Tip, Tier, Age days, PR, Unpushed, Reason. +Then `TipCapture: ` (the durable tip record git-branch-delete.sh requires) +or `TipCaptureError: ` when it could not be written completely. +Restore a branch from a captured tip: git branch + +Does NOT delete branches. Exit: 0 (2 on a usage error). EOF } -case "${1:-}" in --h | --help) - usage - exit 0 - ;; -*) ;; -esac +CAPTURE_ARG="" +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --capture-file) + if [[ -z "${2:-}" ]]; then + echo "git-branch-audit.sh: --capture-file requires a value" >&2 + exit 2 + fi + CAPTURE_ARG="$2" + shift 2 + ;; + *) + echo "git-branch-audit.sh: unknown arg '$1'" >&2 + usage >&2 + exit 2 + ;; + esac +done REPO_ROOT="$(clean_repo_root)" if [[ -z "$REPO_ROOT" ]]; then @@ -45,6 +81,46 @@ DEFAULT_BRANCH="$(clean_default_branch "$REPO_ROOT")" CURRENT_BRANCH="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null | tr -d '\r')" +# Tip capture setup. The capture is opened before the first branch is classified +# and every row is appended as its branch is reported, so the file mirrors the +# output exactly. CAPTURE_ERROR, once set, is sticky: nothing after it can turn a +# failed capture back into a reported path. +CAPTURED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +COMMON_DIR="$(clean_git_common_dir "$REPO_ROOT")" || COMMON_DIR="" +CAPTURE_ERROR="" +CAPTURE_ROWS=0 +if [[ -n "$CAPTURE_ARG" ]]; then + CAPTURE_PATH="$CAPTURE_ARG" +elif [[ -n "$COMMON_DIR" ]]; then + CAPTURE_PATH="$COMMON_DIR/repo-hygiene/branch-tips/$(date -u +%Y%m%dT%H%M%SZ)-$$.tsv" +else + CAPTURE_PATH="" + CAPTURE_ERROR="cannot resolve the git common dir for a default capture location; pass --capture-file PATH" +fi +CAPTURE_TMP="${CAPTURE_PATH}.part" +if [[ -z "$CAPTURE_ERROR" ]]; then + if ! mkdir -p "$(dirname "$CAPTURE_PATH")" 2>/dev/null; then + CAPTURE_ERROR="cannot create $(dirname "$CAPTURE_PATH")" + elif ! (: >"$CAPTURE_TMP") 2>/dev/null; then + CAPTURE_ERROR="cannot write $CAPTURE_TMP" + fi +fi + +capture_line() { + [[ -n "$CAPTURE_ERROR" ]] && return 0 + if ! printf '%s\n' "$1" >>"$CAPTURE_TMP" 2>/dev/null; then + CAPTURE_ERROR="write failed: $CAPTURE_TMP" + fi +} + +capture_line "# repo-hygiene branch tip capture v1" +capture_line "# repo: $REPO_ROOT" +capture_line "# common_dir: ${COMMON_DIR:-unknown}" +capture_line "# default_branch: $DEFAULT_BRANCH" +capture_line "# captured_at: $CAPTURED_AT" +capture_line "# restore: git branch " +capture_line "$(printf '# columns: branch\ttip\ttier\tpr\tupstream\tahead\tbehind\tnot_on_default\tcaptured_at')" + # PR map: branch → state, the mitigation for squash merges that `git --merged` # cannot see. clean_pr_map emits PRCount / PRDataTruncated / PRDataUnavailable # onto this script's stdout, so a short or missing map is visible to the reader @@ -73,7 +149,14 @@ NOW=$(date +%s) classify_branch() { local branch="$1" age_days="$2" tier reason pr_line="none" local_tip - local upstream no_upstream=0 ahead_default="" unpushed_line ahead_up + local upstream no_upstream=0 ahead_default="" unpushed_line ahead_up behind_up="" + + # The tip is the one fact that makes a deleted branch restorable, so it is + # resolved first and reported for every branch regardless of verdict: a + # verdict can be wrong in either direction, and the tip is what recovers from + # that. An unresolvable tip is reported as such and gets no capture row, which + # makes the branch undeletable through git-branch-delete.sh. + local_tip="$(git -C "$REPO_ROOT" rev-parse --verify --quiet "refs/heads/$branch" 2>/dev/null | tr -d '\r')" # No-upstream branches are invisible to `@{upstream}`-based ahead/behind # reporting (it yields nothing), so never-pushed local work goes unseen. Detect @@ -111,7 +194,6 @@ classify_branch() { tier="WORKTREE" reason="checked out in worktree — clean up the worktree first" elif [[ "${PR_STATE[$branch]:-}" == "MERGED" ]]; then - local_tip="$(git -C "$REPO_ROOT" rev-parse "refs/heads/$branch" 2>/dev/null | tr -d '\r')" if [[ -n "${PR_REFOID[$branch]:-}" && -n "$local_tip" && "$local_tip" != "${PR_REFOID[$branch]}" ]]; then tier="REVIEW" reason="PR merged but branch has commits since merge" @@ -174,15 +256,24 @@ classify_branch() { fi else ahead_up="$(git -C "$REPO_ROOT" rev-list --count "${branch}@{upstream}..refs/heads/${branch}" 2>/dev/null | tr -d '\r')" + behind_up="$(git -C "$REPO_ROOT" rev-list --count "refs/heads/${branch}..${branch}@{upstream}" 2>/dev/null | tr -d '\r')" unpushed_line="${ahead_up:-0} ahead of ${upstream}" fi printf 'Branch: %s\n' "$branch" + printf 'Tip: %s\n' "${local_tip:-unresolved}" printf 'Tier: %s\n' "$tier" printf 'Age days: %s\n' "$age_days" printf 'PR: %s\n' "$pr_line" printf 'Unpushed: %s\n' "$unpushed_line" printf 'Reason: %s\n' "$reason" + + if [[ -n "$local_tip" ]]; then + capture_line "$(printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \ + "$branch" "$local_tip" "$tier" "$pr_line" "${upstream:-none}" \ + "${ahead_up:--}" "${behind_up:--}" "${ahead_default:--}" "$CAPTURED_AT")" + CAPTURE_ROWS=$((CAPTURE_ROWS + 1)) + fi } while IFS= read -r line; do @@ -193,5 +284,24 @@ while IFS= read -r line; do classify_branch "$branch" "$age_days" done < <(git -C "$REPO_ROOT" for-each-ref refs/heads/ --format='%(refname:short) %(committerdate:unix)' 2>/dev/null | tr -d '\r') +# Seal the capture: rename the .part into place only after a row count on the +# written file agrees with the rows this run produced. A short write (disk full, +# a vanished mount) therefore surfaces as TipCaptureError, never as a capture +# that silently lacks some of the branches the report above lists. +if [[ -z "$CAPTURE_ERROR" ]]; then + written="$(grep -c -v '^#' "$CAPTURE_TMP" 2>/dev/null | tr -d '\r')" + if [[ "${written:-x}" != "$CAPTURE_ROWS" ]]; then + CAPTURE_ERROR="short write: expected $CAPTURE_ROWS rows, found ${written:-0} in $CAPTURE_TMP" + elif ! mv -f "$CAPTURE_TMP" "$CAPTURE_PATH" 2>/dev/null; then + CAPTURE_ERROR="cannot rename $CAPTURE_TMP into place" + fi +fi +if [[ -n "$CAPTURE_ERROR" ]]; then + [[ -n "$CAPTURE_PATH" ]] && rm -f "$CAPTURE_TMP" 2>/dev/null + printf 'TipCaptureError: %s\n' "$CAPTURE_ERROR" +else + printf 'TipCapture: %s\n' "$CAPTURE_PATH" +fi + printf 'Summary: protected=%s worktree=%s safe=%s likely-safe=%s review=%s\n' "$prot" "$wt" "$safe" "$likely" "$review" exit 0 diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh b/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh index eedc4cddcb..01bf54dbfe 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh @@ -232,6 +232,62 @@ gc_out="$(PATH="$STUB_BIN:$PATH" bash -c "cd '$GC_REPO' && bash '$AUDIT'")" assert_contains "gone + no default to compare fails closed to review" "$gc_out" "Reason: upstream gone, cannot compare against origin/main" assert_not_contains "gone + no default is never likely-safe" "$gc_out" "Tier: LIKELY-SAFE" +# Tip capture: every branch carries its tip as a structured field, and the same +# facts land in a durable TSV under the common git dir whose path the audit +# prints. That file is what git-branch-delete.sh requires before any deletion. +rc=0 +bash "$AUDIT" --bogus >/dev/null 2>&1 || rc=$? +assert_exit "unknown arg exits 2" 2 "$rc" + +git -C "$NU_REPO" checkout -q -b feat/tracked +echo t1 >"$NU_REPO/t1" +git -C "$NU_REPO" add t1 +git -C "$NU_REPO" commit -qm t1 +echo t2 >"$NU_REPO/t2" +git -C "$NU_REPO" add t2 +git -C "$NU_REPO" commit -qm t2 +git -C "$NU_REPO" push -q -u origin feat/tracked +git -C "$NU_REPO" reset -q --hard HEAD~1 +echo t3 >"$NU_REPO/t3" +git -C "$NU_REPO" add t3 +git -C "$NU_REPO" commit -qm t3 +git -C "$NU_REPO" checkout -q main +tracked_tip="$(git -C "$NU_REPO" rev-parse refs/heads/feat/tracked)" +never_tip="$(git -C "$NU_REPO" rev-parse refs/heads/feat/never-pushed)" +tip_out="$(PATH="$STUB_BIN:$PATH" bash -c "cd '$NU_REPO' && bash '$AUDIT'")" +assert_contains "Tip line follows Branch line" "$tip_out" "Branch: feat/never-pushed +Tip: $never_tip +Tier: REVIEW" +assert_contains "Tip line for the tracked branch" "$tip_out" "Tip: $tracked_tip" +assert_not_contains "no branch is left without a resolved tip" "$tip_out" "Tip: unresolved" +cap="$(printf '%s\n' "$tip_out" | sed -n 's/^TipCapture: //p')" +assert_file_exists "TipCapture path exists" "$cap" +assert_contains "capture is under /repo-hygiene/branch-tips/" "$cap" "$NU_REPO/.git/repo-hygiene/branch-tips/" +assert_file_absent "no .part left behind" "$cap.part" +cap_body="$(cat "$cap")" +assert_contains "capture header" "$cap_body" "# repo-hygiene branch tip capture v1" +assert_contains "capture names the restore command" "$cap_body" "# restore: git branch " +assert_contains "capture row: never-pushed (no upstream, 1 not on default)" "$cap_body" "feat/never-pushed $never_tip REVIEW none none - - 1 " +assert_contains "capture row: tracked (ahead 1, behind 1)" "$cap_body" "feat/tracked $tracked_tip REVIEW none origin/feat/tracked 1 1 " +rows="$(grep -c -v '^#' "$cap")" +heads="$(git -C "$NU_REPO" for-each-ref refs/heads/ | wc -l | tr -d ' ')" +[[ "$rows" == "$heads" ]] && pass "capture has one row per local branch ($rows)" || fail "capture has one row per local branch" "$heads" "$rows" + +# Explicit capture path honoured. +explicit_out="$(PATH="$STUB_BIN:$PATH" bash -c "cd '$NU_REPO' && bash '$AUDIT' --capture-file '$TEST_TMPDIR/explicit.tsv'")" +assert_contains "--capture-file path reported" "$explicit_out" "TipCapture: $TEST_TMPDIR/explicit.tsv" +assert_file_exists "--capture-file written" "$TEST_TMPDIR/explicit.tsv" + +# Capture failure is reported as such, never as a path: a directory component +# that is a regular file cannot be created, so the audit has nowhere to write. +printf 'x\n' >"$TEST_TMPDIR/blocker" +err_rc=0 +err_out="$(PATH="$STUB_BIN:$PATH" bash -c "cd '$NU_REPO' && bash '$AUDIT' --capture-file '$TEST_TMPDIR/blocker/cap.tsv'")" || err_rc=$? +assert_exit "capture failure keeps exit 0 (audit is read-only)" 0 "$err_rc" +assert_contains "capture failure reported" "$err_out" "TipCaptureError: cannot create $TEST_TMPDIR/blocker" +assert_not_contains "capture failure prints no TipCapture path" "$err_out" "TipCapture: " +assert_contains "capture failure still reports every tip" "$err_out" "Tip: $tracked_tip" + if [[ $FAILED -ne 0 ]]; then echo "FAILED: $FAILED test(s)" exit 1 diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh b/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh new file mode 100755 index 0000000000..026f4b6391 --- /dev/null +++ b/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.sh @@ -0,0 +1,352 @@ +#!/usr/bin/env bash +# The clean git tier's ONLY sanctioned local-branch deletion path (§4.7). +# +# Deletion here is gated on a branch-tip capture produced by git-branch-audit.sh +# (its `TipCapture: ` line). The script refuses to delete anything unless +# every branch in the batch has a captured tip AND that tip still matches the +# branch's current tip. It is a refusal, not a warning: an operator working +# through eighty branches skims warnings, and a branch deleted without a +# recorded tip has no reflog to come back from. +# +# Per branch, in this order, and only in --apply mode: +# 1. re-verify the live tip against the captured tip (stale plan guard), +# 2. pin the tip under refs/repo-hygiene/deleted/ so gc cannot prune +# the commits out from under the recorded identifier, +# 3. append the deletion to the capture's ledger (.deleted.tsv), +# 4. only then `git branch -d|-D `. +# Any failure in 1 to 3 aborts the batch BEFORE that branch is touched; branches +# already deleted keep their backup ref and ledger row. The batch-wide +# precondition check (capture present, every branch captured, every tip +# unmoved, no protected/worktree/unforced-REVIEW branch) runs before the first +# deletion, so a refused batch deletes nothing at all. +# +# Usage: +# git-branch-delete.sh --capture PATH [--dry-run] [--force-review] BRANCH... +# git-branch-delete.sh --capture PATH --apply [--force-review] BRANCH... +# +# Restore a deleted branch: git branch +# ( is in the capture, in the ledger, and under refs/repo-hygiene/deleted/) +# +# Exit: 0 planned (dry-run) or every branch deleted; 1 a deletion failed or the +# batch aborted mid-way; 2 usage error; 3 refused (precondition unmet, nothing +# deleted). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/clean-common.sh source=lib/cleanup-paths.sh +source "$SCRIPT_DIR/lib/clean-common.sh" + +usage() { + cat <<'EOF' +git-branch-delete.sh - delete local branches, gated on a captured tip per branch. + +Usage: + git-branch-delete.sh --capture PATH [--dry-run] [--force-review] BRANCH... + git-branch-delete.sh --capture PATH --apply [--force-review] BRANCH... + git-branch-delete.sh --help + + --capture PATH the TipCapture: file written by git-branch-audit.sh (required) + --dry-run default; check every precondition and print the plan + --apply delete, after pinning each tip under refs/repo-hygiene/deleted/ + --force-review allow REVIEW-tier branches (SAFE and LIKELY-SAFE need no flag; + PROTECTED and WORKTREE are never deletable here) + +Refuses the whole batch (exit 3, nothing deleted) when the capture is missing, +a branch has no captured tip, or a captured tip no longer matches the branch. +Re-run git-branch-audit.sh to produce a fresh capture. + +Restore a deleted branch: git branch + +Exit: 0 ok; 1 a deletion failed or the batch aborted; 2 usage; 3 refused. +EOF +} + +MODE="dry-run" +CAPTURE="" +FORCE_REVIEW=0 +BRANCHES=() +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --dry-run) + MODE="dry-run" + shift + ;; + --apply) + MODE="apply" + shift + ;; + --force-review) + FORCE_REVIEW=1 + shift + ;; + --capture) + if [[ -z "${2:-}" ]]; then + echo "git-branch-delete.sh: --capture requires a value" >&2 + exit 2 + fi + CAPTURE="$2" + shift 2 + ;; + --) + shift + while [[ $# -gt 0 ]]; do + BRANCHES+=("$1") + shift + done + ;; + -*) + echo "git-branch-delete.sh: unknown arg '$1'" >&2 + usage >&2 + exit 2 + ;; + *) + BRANCHES+=("$1") + shift + ;; + esac +done + +if [[ ${#BRANCHES[@]} -eq 0 ]]; then + echo "git-branch-delete.sh: no branches given" >&2 + usage >&2 + exit 2 +fi + +REPO_ROOT="$(clean_repo_root)" +if [[ -z "$REPO_ROOT" ]]; then + echo "git-branch-delete.sh: not a git repository" >&2 + exit 2 +fi + +# ---- Batch-wide precondition: the capture itself ----------------------------- +# Every refusal is printed as its own `Refused:` line and the batch stops with +# nothing deleted. The message names what is missing and how to produce it. + +REFUSED=0 +refuse() { + printf 'Refused: %s\n' "$1" + REFUSED=$((REFUSED + 1)) +} + +printf 'Capture: %s\n' "${CAPTURE:-none}" +CAPTURE_HINT="run git-branch-audit.sh and pass its TipCapture: path as --capture" +if [[ -z "$CAPTURE" ]]; then + refuse "no --capture given; a deletion batch needs the captured tips of every branch it deletes ($CAPTURE_HINT)" +elif [[ ! -f "$CAPTURE" || ! -r "$CAPTURE" ]]; then + refuse "capture not readable: $CAPTURE ($CAPTURE_HINT)" +fi + +declare -A CAP_TIP=() +declare -A CAP_TIER=() +declare -A CAP_PR=() +CAP_COMMON="" +CAP_ROWS=0 +if [[ $REFUSED -eq 0 ]]; then + first="$(head -n1 "$CAPTURE" 2>/dev/null | tr -d '\r')" + if [[ "$first" != "# repo-hygiene branch tip capture v1" ]]; then + refuse "not a branch tip capture (bad header): $CAPTURE ($CAPTURE_HINT)" + else + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ -z "$line" ]] && continue + if [[ "$line" == '#'* ]]; then + [[ "$line" == '# common_dir: '* ]] && CAP_COMMON="${line#\# common_dir: }" + continue + fi + IFS=$'\t' read -r c_branch c_tip c_tier c_pr _rest <<<"$line" + [[ -z "$c_branch" || -z "$c_tip" ]] && continue + CAP_TIP["$c_branch"]="$c_tip" + CAP_TIER["$c_branch"]="${c_tier:-}" + CAP_PR["$c_branch"]="${c_pr:-none}" + CAP_ROWS=$((CAP_ROWS + 1)) + done <"$CAPTURE" + if [[ $CAP_ROWS -eq 0 ]]; then + refuse "capture holds no branch rows: $CAPTURE ($CAPTURE_HINT)" + fi + fi +fi + +# The capture must describe THIS repository. Compared by common git dir so an +# audit run from a linked worktree and a deletion run from the main checkout +# still agree; a capture from another repository is refused outright. +COMMON_DIR="$(clean_git_common_dir "$REPO_ROOT")" || COMMON_DIR="" +if [[ $REFUSED -eq 0 && -n "$CAP_COMMON" && "$CAP_COMMON" != "unknown" ]]; then + if [[ -z "$COMMON_DIR" || "$(clean_path_key "$CAP_COMMON")" != "$(clean_path_key "$COMMON_DIR")" ]]; then + refuse "capture was taken in a different repository ($CAP_COMMON), not $COMMON_DIR ($CAPTURE_HINT)" + fi +fi + +# ---- Batch-wide precondition: every branch -------------------------------------- + +DEFAULT_BRANCH="$(clean_default_branch "$REPO_ROOT")" +CURRENT_BRANCH="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null | tr -d '\r')" +WORKTREE_BRANCHES="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | grep '^branch' | sed 's|^branch refs/heads/||' | tr -d '\r')" + +live_tip() { + git -C "$REPO_ROOT" rev-parse --verify --quiet "refs/heads/$1" 2>/dev/null | tr -d '\r' +} + +# delete_flag -> -d or -D. Mirrors §4.7: a PR-merged SAFE branch is +# squash-merged more often than not, so `-d` would refuse it; ancestry-merged +# SAFE takes the safe `-d`; LIKELY-SAFE (upstream gone) and forced REVIEW take -D. +delete_flag() { + local tier="$1" pr="$2" + case "$tier" in + SAFE) + if [[ "$pr" == *MERGED* ]]; then printf -- '-D'; else printf -- '-d'; fi + ;; + *) printf -- '-D' ;; + esac +} + +declare -A SEEN=() +PLAN=() +for branch in "${BRANCHES[@]}"; do + branch="${branch%$'\r'}" + [[ -n "${SEEN[$branch]:-}" ]] && continue + SEEN["$branch"]=1 + + if [[ "$branch" == "$CURRENT_BRANCH" ]]; then + refuse "$branch (current branch)" + continue + fi + if [[ "$branch" == "$DEFAULT_BRANCH" ]]; then + refuse "$branch (default branch)" + continue + fi + if clean_branch_matches_protected_pattern "$branch"; then + refuse "$branch (protected pattern)" + continue + fi + if grep -qxF "$branch" <<<"$WORKTREE_BRANCHES"; then + refuse "$branch (checked out in a linked worktree; remove the worktree first)" + continue + fi + + tip="$(live_tip "$branch")" + if [[ -z "$tip" ]]; then + refuse "$branch (no such local branch)" + continue + fi + + [[ $REFUSED -gt 0 && $CAP_ROWS -eq 0 ]] && continue # capture itself already refused + + cap_tip="${CAP_TIP[$branch]:-}" + if [[ -z "$cap_tip" ]]; then + refuse "$branch (no captured tip in $CAPTURE; $CAPTURE_HINT)" + continue + fi + if [[ "$cap_tip" != "$tip" ]]; then + refuse "$branch (tip moved since capture: captured $cap_tip, now $tip; re-run git-branch-audit.sh before deleting)" + continue + fi + + tier="${CAP_TIER[$branch]:-}" + case "$tier" in + SAFE | LIKELY-SAFE) ;; + REVIEW) + if [[ $FORCE_REVIEW -ne 1 ]]; then + refuse "$branch (tier REVIEW; pass --force-review only after the user confirmed the loss named in its Unpushed/Reason lines)" + continue + fi + ;; + *) + refuse "$branch (tier ${tier:-unknown} is never deletable here)" + continue + ;; + esac + + PLAN+=("$branch") +done + +if [[ $REFUSED -gt 0 ]]; then + printf 'Summary: planned=0 refused=%s deleted=0\n' "$REFUSED" + printf 'Refused: nothing deleted; a deletion batch proceeds only when every branch in it passes\n' + exit 3 +fi + +if [[ "$MODE" == "dry-run" ]]; then + for branch in "${PLAN[@]}"; do + printf 'Planned: %s %s (%s, git branch %s)\n' "$branch" "${CAP_TIP[$branch]}" "${CAP_TIER[$branch]}" \ + "$(delete_flag "${CAP_TIER[$branch]}" "${CAP_PR[$branch]}")" + done + printf 'Summary: planned=%s refused=0 deleted=0\n' "${#PLAN[@]}" + printf 'Restore: git branch (tips in %s)\n' "$CAPTURE" + exit 0 +fi + +# ---- Apply ----------------------------------------------------------------------- + +LEDGER="${CAPTURE%.tsv}.deleted.tsv" +BACKUP_NS="refs/repo-hygiene/deleted" +DELETED=0 +FAILED=0 +ABORTED="" + +ledger_line() { + printf '%s\n' "$1" >>"$LEDGER" 2>/dev/null +} + +if [[ ! -f "$LEDGER" ]]; then + if ! ledger_line "$(printf '# repo-hygiene branch deletion ledger v1\n# capture: %s\n# restore: git branch \n# columns: branch\ttip\tdeleted_at\tbackup_ref\ttier' "$CAPTURE")"; then + printf 'Aborted: cannot write ledger %s; nothing deleted\n' "$LEDGER" + printf 'Summary: planned=%s refused=0 deleted=0 failed=0 aborted=1\n' "${#PLAN[@]}" + exit 1 + fi +fi + +printf 'Ledger: %s\n' "$LEDGER" +printf 'BackupRefs: %s/\n' "$BACKUP_NS" + +for branch in "${PLAN[@]}"; do + tip="${CAP_TIP[$branch]}" + tier="${CAP_TIER[$branch]}" + + # 1. Stale-plan guard, again, immediately before the destructive step. + now_tip="$(live_tip "$branch")" + if [[ "$now_tip" != "$tip" ]]; then + ABORTED="$branch (tip moved between check and delete: captured $tip, now ${now_tip:-gone})" + break + fi + + # 2. Pin the tip so a later gc cannot prune the commits the record points at. + if ! git -C "$REPO_ROOT" update-ref "$BACKUP_NS/$branch" "$tip" 2>/dev/null; then + ABORTED="$branch (could not write backup ref $BACKUP_NS/$branch)" + break + fi + + # 3. Record the deletion durably BEFORE it happens. + if ! ledger_line "$(printf '%s\t%s\t%s\t%s\t%s' "$branch" "$tip" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BACKUP_NS/$branch" "$tier")"; then + ABORTED="$branch (could not append to ledger $LEDGER)" + break + fi + + # 4. Delete. + flag="$(delete_flag "$tier" "${CAP_PR[$branch]}")" + if err="$(git -C "$REPO_ROOT" branch "$flag" "$branch" 2>&1 >/dev/null)"; then + printf 'Deleted: %s %s (was %s) restore: git branch %s %s\n' "$branch" "$tip" "$tier" "$branch" "$tip" + DELETED=$((DELETED + 1)) + else + err="$(printf '%s' "$err" | tr -d '\r' | head -n1)" + printf 'Failed: %s (%s)\n' "$branch" "${err:-git branch $flag failed}" + FAILED=$((FAILED + 1)) + fi +done + +if [[ -n "$ABORTED" ]]; then + printf 'Aborted: %s\n' "$ABORTED" + remaining=$((${#PLAN[@]} - DELETED - FAILED)) + printf 'Summary: planned=%s refused=0 deleted=%s failed=%s aborted=1 untouched=%s\n' \ + "${#PLAN[@]}" "$DELETED" "$FAILED" "$remaining" + printf 'Restore: git branch (tips in %s and %s; pinned under %s/)\n' "$CAPTURE" "$LEDGER" "$BACKUP_NS" + exit 1 +fi + +printf 'Summary: planned=%s refused=0 deleted=%s failed=%s\n' "${#PLAN[@]}" "$DELETED" "$FAILED" +printf 'Restore: git branch (tips in %s and %s; pinned under %s/)\n' "$CAPTURE" "$LEDGER" "$BACKUP_NS" +[[ $FAILED -eq 0 ]] || exit 1 +exit 0 diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh b/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh new file mode 100644 index 0000000000..3cc59fd9f9 --- /dev/null +++ b/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Tests for git-branch-delete.sh: the tip-capture precondition, the fail-closed +# batch semantics, and the end-to-end property that a branch deleted through +# this path is restorable from the artifact the audit produced, with no other +# information. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/test-helpers.sh +source "$SCRIPT_DIR/lib/test-helpers.sh" + +AUDIT="$SCRIPT_DIR/git-branch-audit.sh" +DELETE="$SCRIPT_DIR/git-branch-delete.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT +FAILED=0 + +# A no-op gh stub keeps every case offline and deterministic (the real gh would +# hit the network for the audit's PR map). +STUB_BIN="$TEST_TMPDIR/stub-bin" +mkdir -p "$STUB_BIN" +printf '#!/usr/bin/env bash\nexit 1\n' >"$STUB_BIN/gh" +chmod +x "$STUB_BIN/gh" +export PATH="$STUB_BIN:$PATH" + +rc=0 +bash "$DELETE" --help >/dev/null 2>&1 || rc=$? +assert_exit "--help exits 0" 0 "$rc" + +# ---- Fixture ----------------------------------------------------------------- +# main pushed to a bare origin (so ancestry-merged branches classify SAFE), plus: +# feat/safe1, feat/safe2 merged into main with --no-ff -> SAFE, distinct tips +# feat/likely merged, pushed, then deleted on origin -> SAFE +# (ancestry, priority 6, outranks the gone upstream) +# feat/still, feat/moved merged; feat/moved gains a commit after the audit +# feat/review unmerged local work -> REVIEW +# feat/parked checked out in a linked worktree -> WORKTREE +# release/1 protected pattern +REPO="$TEST_TMPDIR/repo" +git init -q --bare "$TEST_TMPDIR/origin.git" +git init -q -b main "$REPO" +git -C "$REPO" config user.email "t@example.com" +git -C "$REPO" config user.name "Test" +echo base >"$REPO/base" +git -C "$REPO" add base +git -C "$REPO" commit -qm base +git -C "$REPO" remote add origin "$TEST_TMPDIR/origin.git" + +merged_branch() { # : branch with one commit, merged --no-ff into main + git -C "$REPO" checkout -q -b "$1" + echo "$2" >"$REPO/$2" + git -C "$REPO" add "$2" + git -C "$REPO" commit -qm "$2" + git -C "$REPO" checkout -q main + git -C "$REPO" merge -q --no-ff -m "merge $1" "$1" +} +merged_branch feat/safe1 s1 +merged_branch feat/safe2 s2 +merged_branch feat/still st +merged_branch feat/moved mv +merged_branch feat/ok1 o1 +merged_branch feat/blocked bl +merged_branch feat/ok2 o2 +git -C "$REPO" push -q -u origin main +git -C "$REPO" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main + +git -C "$REPO" checkout -q -b feat/likely +echo l >"$REPO/l" +git -C "$REPO" add l +git -C "$REPO" commit -qm l +git -C "$REPO" checkout -q main +git -C "$REPO" merge -q --no-ff -m "merge likely" feat/likely +git -C "$REPO" push -q origin main +git -C "$REPO" push -q -u origin feat/likely +git -C "$REPO" push -q origin --delete feat/likely +git -C "$REPO" fetch -q --prune origin + +git -C "$REPO" checkout -q -b feat/review +echo r >"$REPO/r" +git -C "$REPO" add r +git -C "$REPO" commit -qm r +git -C "$REPO" checkout -q main + +git -C "$REPO" branch feat/parked +git -C "$REPO" worktree add -q "$TEST_TMPDIR/wt-parked" feat/parked +git -C "$REPO" branch release/1 + +branch_exists() { git -C "$REPO" rev-parse --verify --quiet "refs/heads/$1" >/dev/null 2>&1; } +run_delete() { (cd "$REPO" && bash "$DELETE" "$@"); } + +# ---- No capture: refused, nothing deleted ----------------------------------- +out="$(run_delete feat/safe1 2>&1)" +rc=$? +assert_exit "no --capture exits 3" 3 "$rc" +assert_contains "no --capture names what is missing" "$out" "Refused: no --capture given" +assert_contains "no --capture says how to produce it" "$out" "run git-branch-audit.sh and pass its TipCapture: path" +assert_not_contains "no --capture deletes nothing" "$out" "Deleted:" +branch_exists feat/safe1 && pass "no --capture: feat/safe1 still exists" || fail "no --capture: feat/safe1 still exists" "exists" "gone" + +out="$(run_delete --capture "$TEST_TMPDIR/does-not-exist.tsv" feat/safe1 2>&1)" +rc=$? +assert_exit "unreadable capture exits 3" 3 "$rc" +assert_contains "unreadable capture refused" "$out" "Refused: capture not readable" + +printf 'not a capture\n' >"$TEST_TMPDIR/bogus.tsv" +out="$(run_delete --capture "$TEST_TMPDIR/bogus.tsv" feat/safe1 2>&1)" +rc=$? +assert_exit "bad header exits 3" 3 "$rc" +assert_contains "bad header refused" "$out" "bad header" + +# ---- Audit produces the capture -------------------------------------------- +audit_out="$(cd "$REPO" && bash "$AUDIT")" +CAP="$(printf '%s\n' "$audit_out" | sed -n 's/^TipCapture: //p')" +assert_file_exists "audit wrote a capture" "$CAP" +assert_contains "capture lives under the common git dir" "$CAP" "$REPO/.git/repo-hygiene/branch-tips/" +safe1_tip="$(git -C "$REPO" rev-parse refs/heads/feat/safe1)" +safe2_tip="$(git -C "$REPO" rev-parse refs/heads/feat/safe2)" +likely_tip="$(git -C "$REPO" rev-parse refs/heads/feat/likely)" +assert_contains "audit classifies feat/safe1 SAFE" "$audit_out" "Branch: feat/safe1 +Tip: $safe1_tip +Tier: SAFE" +assert_contains "audit reports feat/likely tip with its verdict" "$audit_out" "Branch: feat/likely +Tip: $likely_tip +Tier: SAFE" + +# ---- Dry-run plans, deletes nothing ------------------------------------------ +out="$(run_delete --capture "$CAP" feat/safe1 feat/safe2 feat/likely 2>&1)" +rc=$? +assert_exit "dry-run exits 0" 0 "$rc" +assert_contains "dry-run plans safe1 with its tip" "$out" "Planned: feat/safe1 $safe1_tip (SAFE, git branch -d)" +assert_contains "dry-run plans likely (gone upstream, merged) with -d" "$out" "Planned: feat/likely $likely_tip (SAFE, git branch -d)" +assert_contains "dry-run summary" "$out" "Summary: planned=3 refused=0 deleted=0" +assert_contains "dry-run tells how to restore" "$out" "Restore: git branch " +assert_not_contains "dry-run deletes nothing" "$out" "Deleted:" +branch_exists feat/safe1 && branch_exists feat/likely && pass "dry-run: branches intact" || fail "dry-run: branches intact" "intact" "deleted" +assert_file_absent "dry-run writes no ledger" "${CAP%.tsv}.deleted.tsv" + +# ---- Tier gates --------------------------------------------------------------- +out="$(run_delete --capture "$CAP" feat/review 2>&1)" +rc=$? +assert_exit "REVIEW without --force-review exits 3" 3 "$rc" +assert_contains "REVIEW refused names the flag" "$out" "Refused: feat/review (tier REVIEW; pass --force-review" +out="$(run_delete --capture "$CAP" --force-review feat/review 2>&1)" +rc=$? +assert_exit "REVIEW with --force-review plans" 0 "$rc" +assert_contains "forced REVIEW uses -D" "$out" "(REVIEW, git branch -D)" + +out="$(run_delete --capture "$CAP" --force-review release/1 main feat/parked 2>&1)" +rc=$? +assert_exit "protected/current/worktree exit 3 even forced" 3 "$rc" +assert_contains "protected pattern refused" "$out" "Refused: release/1 (protected pattern)" +assert_contains "current branch refused" "$out" "Refused: main (current branch)" +assert_contains "worktree branch refused" "$out" "Refused: feat/parked (checked out in a linked worktree" + +# ---- One bad branch refuses the whole batch --------------------------------- +out="$(run_delete --capture "$CAP" --apply feat/safe1 feat/review 2>&1)" +rc=$? +assert_exit "mixed batch exits 3" 3 "$rc" +assert_not_contains "mixed batch deletes nothing" "$out" "Deleted:" +branch_exists feat/safe1 && pass "mixed batch: deletable feat/safe1 untouched" || fail "mixed batch: deletable feat/safe1 untouched" "exists" "gone" + +# ---- Tip moved between capture and delete: batch stops ---------------------- +git -C "$REPO" checkout -q feat/moved +echo more >>"$REPO/mv" +git -C "$REPO" add mv +git -C "$REPO" commit -qm "moved after audit" +git -C "$REPO" checkout -q main +moved_tip_now="$(git -C "$REPO" rev-parse refs/heads/feat/moved)" +out="$(run_delete --capture "$CAP" --apply feat/still feat/moved 2>&1)" +rc=$? +assert_exit "moved tip exits 3" 3 "$rc" +assert_contains "moved tip refused names both shas" "$out" "Refused: feat/moved (tip moved since capture: captured" +assert_contains "moved tip refused names current sha" "$out" "now $moved_tip_now" +assert_not_contains "moved tip: nothing deleted" "$out" "Deleted:" +branch_exists feat/still && pass "moved tip: unmoved sibling feat/still untouched" || fail "moved tip: unmoved sibling feat/still untouched" "exists" "gone" +branch_exists feat/moved && pass "moved tip: feat/moved untouched" || fail "moved tip: feat/moved untouched" "exists" "gone" + +# ---- Branch absent from the capture: refused -------------------------------- +grep -v $'^feat/still\t' "$CAP" >"$TEST_TMPDIR/short.tsv" +out="$(run_delete --capture "$TEST_TMPDIR/short.tsv" --apply feat/still 2>&1)" +rc=$? +assert_exit "missing row exits 3" 3 "$rc" +assert_contains "missing row refused" "$out" "Refused: feat/still (no captured tip in" +branch_exists feat/still && pass "missing row: feat/still untouched" || fail "missing row: feat/still untouched" "exists" "gone" + +# ---- Capture from another repository: refused ------------------------------- +OTHER="$TEST_TMPDIR/other" +git init -q -b main "$OTHER" +git -C "$OTHER" config user.email "t@example.com" +git -C "$OTHER" config user.name "Test" +echo o >"$OTHER/o" +git -C "$OTHER" add o +git -C "$OTHER" commit -qm o +git -C "$OTHER" branch feat/still +other_cap="$(cd "$OTHER" && bash "$AUDIT" | sed -n 's/^TipCapture: //p')" +out="$(run_delete --capture "$other_cap" --apply feat/still 2>&1)" +rc=$? +assert_exit "foreign capture exits 3" 3 "$rc" +assert_contains "foreign capture refused" "$out" "different repository" + +# ---- Apply: capture before delete, ledger, backup ref, restore -------------- +out="$(run_delete --capture "$CAP" --apply feat/safe1 feat/safe2 feat/likely 2>&1)" +rc=$? +assert_exit "apply exits 0" 0 "$rc" +assert_contains "apply reports safe1 with restore command" "$out" "Deleted: feat/safe1 $safe1_tip (was SAFE) restore: git branch feat/safe1 $safe1_tip" +assert_contains "apply reports likely" "$out" "Deleted: feat/likely $likely_tip (was SAFE)" +assert_contains "apply summary" "$out" "Summary: planned=3 refused=0 deleted=3 failed=0" +assert_contains "apply names the ledger" "$out" "Ledger: ${CAP%.tsv}.deleted.tsv" +branch_exists feat/safe1 && fail "apply: feat/safe1 deleted" "gone" "exists" || pass "apply: feat/safe1 deleted" +branch_exists feat/likely && fail "apply: feat/likely deleted" "gone" "exists" || pass "apply: feat/likely deleted" +LEDGER="${CAP%.tsv}.deleted.tsv" +assert_file_exists "ledger written" "$LEDGER" +assert_contains "ledger row for safe1" "$(cat "$LEDGER")" "feat/safe1 $safe1_tip " +assert_contains "ledger row for safe2" "$(cat "$LEDGER")" "feat/safe2 $safe2_tip " +backup="$(git -C "$REPO" rev-parse --verify --quiet refs/repo-hygiene/deleted/feat/safe1)" +[[ "$backup" == "$safe1_tip" ]] && pass "backup ref pins safe1 tip" || fail "backup ref pins safe1 tip" "$safe1_tip" "${backup:-none}" + +# The end-to-end property. Only the capture file is consulted: branch name in, +# tip out, `git branch `. gc --prune=now first, so the restore also +# proves the pinned tip survived the prune the git tier runs after a deletion. +git -C "$REPO" gc -q --prune=now 2>/dev/null +for b in feat/safe1 feat/safe2 feat/likely; do + tip="$(awk -F'\t' -v b="$b" '!/^#/ && $1==b {print $2}' "$CAP")" + if [[ -n "$tip" ]] && git -C "$REPO" branch "$b" "$tip" 2>/dev/null && + [[ "$(git -C "$REPO" rev-parse "refs/heads/$b")" == "$tip" ]]; then + pass "restored $b from the capture alone" + else + fail "restored $b from the capture alone" "$tip" "$(git -C "$REPO" rev-parse --verify --quiet "refs/heads/$b" || echo none)" + fi +done +[[ "$(git -C "$REPO" show feat/safe1:s1 2>/dev/null)" == "s1" ]] && pass "restored safe1 carries its content" || fail "restored safe1 carries its content" "s1" "missing" + +# Control for the pin: the same deletion without the backup ref loses the commit +# to the same prune, which is why step 2 precedes the delete. +CTRL="$TEST_TMPDIR/ctrl" +git init -q -b main "$CTRL" +git -C "$CTRL" config user.email "t@example.com" +git -C "$CTRL" config user.name "Test" +echo c >"$CTRL/c" +git -C "$CTRL" add c +git -C "$CTRL" commit -qm c +git -C "$CTRL" checkout -q -b feat/unpinned +echo u >"$CTRL/u" +git -C "$CTRL" add u +git -C "$CTRL" commit -qm u +unpinned_tip="$(git -C "$CTRL" rev-parse HEAD)" +git -C "$CTRL" checkout -q main +git -C "$CTRL" branch -D feat/unpinned >/dev/null +git -C "$CTRL" reflog expire --expire=now --all +git -C "$CTRL" gc -q --prune=now 2>/dev/null +git -C "$CTRL" cat-file -e "$unpinned_tip" 2>/dev/null && fail "control: unpinned tip pruned" "pruned" "present" || pass "control: unpinned tip pruned by gc --prune=now" + +# ---- Apply aborts mid-batch, before touching the blocked branch ------------- +# A ref nested under the backup name makes update-ref fail for feat/blocked +# (directory/file conflict). Fresh capture: feat/safe* were recreated above. +CAP2="$(cd "$REPO" && bash "$AUDIT" | sed -n 's/^TipCapture: //p')" +git -C "$REPO" update-ref refs/repo-hygiene/deleted/feat/blocked/child HEAD +out="$(run_delete --capture "$CAP2" --apply feat/ok1 feat/blocked feat/ok2 2>&1)" +rc=$? +assert_exit "backup-ref failure exits 1" 1 "$rc" +assert_contains "ok1 deleted before the abort" "$out" "Deleted: feat/ok1 " +assert_contains "abort names the backup ref" "$out" "Aborted: feat/blocked (could not write backup ref" +assert_contains "abort summary counts untouched (blocked + ok2)" "$out" "deleted=1 failed=0 aborted=1 untouched=2" +branch_exists feat/blocked && pass "abort: feat/blocked untouched" || fail "abort: feat/blocked untouched" "exists" "gone" +branch_exists feat/ok2 && pass "abort: feat/ok2 untouched" || fail "abort: feat/ok2 untouched" "exists" "gone" +git -C "$REPO" update-ref -d refs/repo-hygiene/deleted/feat/blocked/child + +# ---- Ledger unwritable: nothing deleted ------------------------------------- +CAP3="$(cd "$REPO" && bash "$AUDIT" --capture-file "$TEST_TMPDIR/cap3.tsv" | sed -n 's/^TipCapture: //p')" +mkdir -p "$TEST_TMPDIR/cap3.deleted.tsv" # a directory where the ledger must go +out="$(run_delete --capture "$CAP3" --apply feat/ok2 2>&1)" +rc=$? +assert_exit "unwritable ledger exits 1" 1 "$rc" +assert_contains "unwritable ledger aborts" "$out" "Aborted: cannot write ledger" +assert_not_contains "unwritable ledger deletes nothing" "$out" "Deleted:" +branch_exists feat/ok2 && pass "unwritable ledger: feat/ok2 untouched" || fail "unwritable ledger: feat/ok2 untouched" "exists" "gone" + +if [[ $FAILED -ne 0 ]]; then + echo "FAILED: $FAILED test(s)" + exit 1 +fi +echo "OK: git-branch-delete.sh tests passed" diff --git a/plugins/repo-hygiene/skills/clean/scripts/lib/clean-common.sh b/plugins/repo-hygiene/skills/clean/scripts/lib/clean-common.sh index 782312dd73..00286e0fec 100644 --- a/plugins/repo-hygiene/skills/clean/scripts/lib/clean-common.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/lib/clean-common.sh @@ -16,6 +16,25 @@ clean_repo_root() { printf '%s' "$root" } +# clean_git_common_dir - print the absolute path of the repository's +# common git directory: the main checkout's `.git` even when is a +# linked worktree. The branch-tip capture lives under it because it is the one +# location that outlives the working tree (a `tree` reset never enters `.git`, +# and removing a linked worktree leaves the common dir untouched) and is +# findable afterwards by anyone who knows only the repository. Exit 1 when it +# cannot be resolved; callers treat that as "no durable location", never as +# permission to skip the capture. +clean_git_common_dir() { + local repo_root="$1" dir + dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null | tr -d '\r')" + [[ -n "$dir" ]] || return 1 + case "$dir" in + /* | [A-Za-z]:*) ;; + *) dir="$repo_root/$dir" ;; + esac + (cd "$dir" 2>/dev/null && pwd -P) || return 1 +} + # clean_default_branch [] — echo the repository's default # branch: the remote's HEAD symref (default remote `origin`; the tree tier passes # the remote the current branch actually tracks), else gh's view of it, else the From 0532dbbf0bae2f48fe54cceb4f3e27997fce023b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 02:26:47 +0000 Subject: [PATCH 2/6] test(repo-hygiene): make the tip-capture assertions explicit if/else Replaces the `A && pass || fail` chains (SC2015) with assert_branch and assert_eq helpers so a failing pass() can never fall through to fail(). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --- .../clean/scripts/git-branch-audit.test.sh | 6 ++- .../clean/scripts/git-branch-delete.test.sh | 43 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh b/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh index 01bf54dbfe..389a86abce 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh @@ -271,7 +271,11 @@ assert_contains "capture row: never-pushed (no upstream, 1 not on default)" "$ca assert_contains "capture row: tracked (ahead 1, behind 1)" "$cap_body" "feat/tracked $tracked_tip REVIEW none origin/feat/tracked 1 1 " rows="$(grep -c -v '^#' "$cap")" heads="$(git -C "$NU_REPO" for-each-ref refs/heads/ | wc -l | tr -d ' ')" -[[ "$rows" == "$heads" ]] && pass "capture has one row per local branch ($rows)" || fail "capture has one row per local branch" "$heads" "$rows" +if [[ "$rows" == "$heads" ]]; then + pass "capture has one row per local branch ($rows)" +else + fail "capture has one row per local branch" "$heads" "$rows" +fi # Explicit capture path honoured. explicit_out="$(PATH="$STUB_BIN:$PATH" bash -c "cd '$NU_REPO' && bash '$AUDIT' --capture-file '$TEST_TMPDIR/explicit.tsv'")" diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh b/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh index 3cc59fd9f9..48f122249b 100644 --- a/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh @@ -88,6 +88,18 @@ git -C "$REPO" branch release/1 branch_exists() { git -C "$REPO" rev-parse --verify --quiet "refs/heads/$1" >/dev/null 2>&1; } run_delete() { (cd "$REPO" && bash "$DELETE" "$@"); } +# assert_branch