Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/repo-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
47 changes: 47 additions & 0 deletions plugins/repo-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,53 @@
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. The `.part` is
created exclusively (`noclobber`), so a stamp-pid collision or an interrupted run is refused
rather than interleaved into one file, and rows are recognised by shape (nine columns, a commit
id second), so a branch whose name begins with `#` is a row, never a miscounted comment that
fails the seal.
- `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/<branch>` so `gc` cannot prune the commits the record points at,
appends the deletion to `<capture>.deleted.tsv` (beside the capture's real file, symlinks
resolved), and only then deletes with `git update-ref -d refs/heads/<branch> <tip>`, an atomic
compare-and-delete that refuses a tip that moved after the re-check, the window between the pin
and the delete that a plain `git branch -D` leaves open; a failure in any earlier step aborts the
batch before that branch is touched, and a refused delete leaves the branch and its pin in place
with a `# not deleted:` note in the ledger. A SAFE-by-ancestry row is admitted only when its tip
is merged into `origin/<default>`, and refused up front otherwise. Every `Deleted:` line and the
closing `Restore:` line carry the restore command. The test deletes branches through the script,
runs `gc --prune=now`, restores them from the capture alone, and opens both tip-move windows
deterministically (a FIFO at the ledger path parks the script between pin and delete) to show
that neither can remove a moved tip. 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.

### Fixed

- `git-branch-delete.sh` treats a SAFE row as a force delete only when the captured `pr` matches
the audit's exact merged-PR format (`#<n> MERGED` or `#<n> MERGED (tip drift)`). A substring
containing MERGED is not enough: both `tier` and `pr` are untrusted capture text, and a bare
substring skipped the ancestry check that is the last verification for SAFE-by-ancestry deletes.
- An unresolved capture `common_dir` (missing, empty, or the literal `unknown`) is a refusal, not
a skipped check. That field is the only gate that the capture describes this repository, so
delete fails closed the same way `git-branch-audit.sh` already does when it cannot resolve one.

## [0.10.34]

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions plugins/repo-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<default>` 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: <path>` (under the main checkout's `.git/repo-hygiene/branch-tips/`). Deletion goes only through `${CLAUDE_SKILL_DIR}/scripts/git-branch-delete.sh --capture <that path>`, 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, a tip moved since the audit, or the capture's `# common_dir:` is missing or `unknown` (that field is the only check that the capture describes this repository, so an unresolved value is a refusal, not a skipped check); re-run the audit rather than deleting against a stale identifier. A `SAFE` row is a force delete only when its captured `pr` matches the audit's exact merged-PR format (`#<n> MERGED`); a `pr` that merely contains the substring MERGED still takes the ancestry check, because both columns are untrusted capture text. A `TipCaptureError:` line means no deletion can proceed from that run. Relay the script's `Restore: git branch <branch> <tip>` 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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/repo-hygiene/skills/clean/context/action-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`), 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 <path> --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) |
Expand Down
45 changes: 37 additions & 8 deletions plugins/repo-hygiene/skills/clean/context/git-branch-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <n>` or `PRDataUnavailable: <why>`, optionally followed by `PRDataTruncated: <why>`; 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: <n>` or `PRDataUnavailable: <why>`, optionally followed by `PRDataTruncated: <why>`; then per branch `Branch:`, `Tip:`, `Tier:`, `Age days:`, `PR:`, `Unpushed:`, `Reason:`; then exactly one of `TipCapture: <path>` or `TipCaptureError: <why>`; 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: `<git-common-dir>/repo-hygiene/branch-tips/<utc-stamp>-<pid>.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 <upstream>`. With no upstream: `no upstream, M commits not on origin/<default>` (or `no upstream (no origin/<default> 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`.
Expand Down Expand Up @@ -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 <TipCapture path> --dry-run <branch>...
```
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 it is a safe delete, admitted only because the tip is merged into `origin/<default>`, or a force delete) 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 <TipCapture path> --apply <branch>...
```

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/<branch>` (so a later `gc` cannot prune the commits the record points at), appends the deletion to the ledger `<capture>.deleted.tsv` (beside the capture's real file, symlinks resolved), and only then deletes, with `git update-ref -d refs/heads/<branch> <captured tip>`: an atomic compare-and-delete inside git's ref lock, which refuses when the tip is no longer the captured one. The re-check closes the window between the batch check and the pin; the conditional delete closes the window between the pin and the delete, which a plain `git branch -D` leaves open. A SAFE-by-ancestry branch is a safe delete, admitted at the batch check only when its tip is merged into `origin/<default>` (the check `git branch -d` would have made). A SAFE row is a force delete only when its captured `pr` matches the audit's exact merged-PR format (`#<n> MERGED` or `#<n> MERGED (tip drift)`): a squash merge changes the SHA, so the ancestry check would refuse a branch whose merge `gh pr list` already confirmed, but both `tier` and `pr` are untrusted capture text, so a `pr` that merely contains the substring MERGED still takes the ancestry check. LIKELY-SAFE is a force delete. A capture whose `# common_dir:` is missing or the literal `unknown` is refused: that field is the only check that the capture describes this repository, so an unresolved value is a refusal, not a skipped check. 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 delete refused because the tip moved also aborts the batch: the branch stays at its new tip, its pin stays (harmless; it records the tip the audit saw), and the ledger gains a `# not deleted:` note. A refusal (a branch whose tip moved since the audit, a branch with no captured tip, a SAFE-by-ancestry row whose tip is not merged, a foreign, unreadable, or unresolved-`common_dir` 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 <tip> (was SAFE) restore: git branch feat/old-thing <tip>
Aborted: some-branch (tip moved between pin and delete: captured <tip>, now <sha>; branch left intact, pin refs/repo-hygiene/deleted/some-branch still records <tip>)
Summary: planned=N refused=0 deleted=N failed=0 aborted=1 untouched=U
Restore: git branch <branch> <tip> (tips in <capture> and <ledger>; pinned under refs/repo-hygiene/deleted/<branch>)
```

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 `<stamp>.deleted.tsv` lists only branches actually deleted; the capture `<stamp>.tsv` lists every branch the audit saw, deleted or not). Or read the pin directly: `git rev-parse refs/repo-hygiene/deleted/<branch>`.
2. Recreate the branch: `git branch <branch> <tip>` (or `git branch <branch> refs/repo-hygiene/deleted/<branch>`).
3. Optionally drop the pin once the branch is back or is no longer wanted: `git update-ref -d refs/repo-hygiene/deleted/<branch>`. 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`.
Loading
Loading