From a69ba0596c6e489d889bfa579cfece7b2f19a15d Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 1 Feb 2026 08:53:47 -0500 Subject: [PATCH 1/3] Add branch-cleanup slash command for merged branch deletion Co-Authored-By: Claude Opus 4.5 --- .claude/commands/branch-cleanup.md | 254 +++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 .claude/commands/branch-cleanup.md diff --git a/.claude/commands/branch-cleanup.md b/.claude/commands/branch-cleanup.md new file mode 100644 index 00000000..05417f03 --- /dev/null +++ b/.claude/commands/branch-cleanup.md @@ -0,0 +1,254 @@ +--- +description: Delete local branches whose PRs have been merged on GitHub +argument-hint: "[--dry-run] [--yes]" +--- + +# Branch Cleanup + +Identify and delete local git branches whose pull requests have been merged. Arguments: $ARGUMENTS + +## Arguments + +`$ARGUMENTS` may contain: +- `--dry-run` (optional): Show what would be deleted without deleting anything. +- `--yes` / `-y` (optional): Skip the confirmation prompt and delete immediately. + +Parse by splitting `$ARGUMENTS` on whitespace. Recognise `--dry-run`, `--yes`, and `-y`. If any other token is present, emit a warning (e.g., `Warning: unrecognised argument '', ignoring`) but continue. + +## Instructions + +### 1. Verify Prerequisites + +1. **Confirm git repo**: + ```bash + git rev-parse --is-inside-work-tree + ``` + If this fails, abort: + ``` + Error: Not inside a git repository. + ``` + +2. **Confirm `origin` remote exists**: + ```bash + git remote get-url origin + ``` + If this fails, abort: + ``` + Error: No 'origin' remote configured. + ``` + Then show `git remote -v` output so the user can see what remotes exist. + +3. **Check `gh` CLI availability**: + ```bash + gh --version 2>/dev/null + ``` + If not available, set a flag `GH_AVAILABLE=false` and warn: + ``` + Warning: GitHub CLI (gh) not found. Falling back to git-only detection. + Squash-merged or rebase-merged branches may not be detected automatically. + ``` + +4. **Record current branch**: + ```bash + git branch --show-current + ``` + +5. **Detect worktree-checked-out branches**: + ```bash + git worktree list --porcelain + ``` + Parse all `branch refs/heads/` lines to build a set of branches checked out in worktrees. + +### 2. Sync Remote State + +```bash +git fetch --prune origin +``` + +- If fetch fails and `--dry-run` is **not** set, abort: + ``` + Error: Failed to fetch from origin. Check your network connection. + ``` +- If fetch fails and `--dry-run` **is** set, warn and continue with stale data: + ``` + Warning: Fetch failed. Showing results based on last fetch. + ``` + +### 3. Enumerate Local Branches + +```bash +git branch --format='%(refname:short)' +``` + +Filter out **protected branches** that must never be deleted: +- `main` +- `master` +- The current branch (from step 1.4) +- Any branch checked out in a worktree (from step 1.5) + +### 4. Detect Merged-PR Candidates + +For each remaining branch: + +#### 4a. GitHub CLI detection (if `GH_AVAILABLE`) + +```bash +gh pr list --state merged --head "" --json number,title --limit 1 +``` + +- If the JSON array is non-empty, mark the branch as **gh-confirmed merged** and record the PR number and title. +- If the array is empty, the branch has no merged PR via this method — continue to 4b only if running the git-only fallback in parallel, otherwise skip. + +#### 4b. Git gone-tracking detection (fallback, or supplement when gh unavailable) + +Parse the output of: +```bash +git branch -vv +``` + +A branch whose tracking info shows `[origin/...: gone]` is a **gone-tracking** candidate. This means the remote branch was deleted (typically after PR merge). + +#### Deduplication + +If both methods identify the same branch, keep the **gh-confirmed** status (it enables safer `-D` deletion). + +### 5. Enrich Candidates + +For each candidate branch, collect: + +- **Last commit subject**: + ```bash + git log -1 --format="%s" -- "" + ``` + Note: use the branch ref, not `--`: `git log -1 --format="%s" ""` + +- **Relative age**: + ```bash + git log -1 --format="%ar" "" + ``` + +- **PR number and title** (from step 4a, if available) + +### 6. Display Candidates + +Present a table: + +``` +Merged branches found: + + Branch Last Commit PR Age + ──────────────── ────────────────────────────── ──────── ────────── + fix/typo-readme Fix typo in README #42 3 days ago + feature/bacon Add Bacon decomposition #38 2 weeks ago + old-experiment Refactor linalg backend (gone) 1 month ago +``` + +- For gh-confirmed branches, show `#` in the PR column. +- For gone-tracking-only branches, show `(gone)` in the PR column. +- If the current branch was a candidate, note it was skipped: + ``` + Note: Current branch '' also has a merged PR but cannot be deleted while checked out. + Switch to another branch first if you want to clean it up. + ``` +- If a worktree branch was a candidate, note it was skipped: + ``` + Note: Branch '' skipped — checked out in worktree at . + ``` + +### 7. Handle No Candidates + +If no candidates were found, report and exit: + +``` +No merged branches found. Your local branches are clean. +``` + +### 8. Confirm Deletion + +- If `--dry-run`: print `Dry run — nothing was deleted.` and exit. +- If `--yes` / `-y`: skip confirmation and proceed to step 9. +- Otherwise, use **AskUserQuestion** with: + - Option 1: "Delete all N branches" + - Option 2: "Cancel" + +If the user chooses "Cancel", exit without deleting. + +### 9. Delete Branches + +For each candidate: + +- **gh-confirmed merged**: use force delete (safe — GitHub confirms the work is preserved): + ```bash + git branch -D -- "" + ``` +- **Gone-tracking only** (no gh confirmation): use safe delete: + ```bash + git branch -d -- "" + ``` + If `-d` fails (common with squash/rebase merges where commit hashes differ), record the branch as a failure. + +### 10. Report Results + +Print a summary: + +``` +Branch cleanup complete. + + Deleted: N branches + Failed: M branches + Skipped: K branches (protected/checked-out) +``` + +If any branches failed deletion: +``` +The following branches could not be deleted with safe delete (likely squash-merged): + + + +To delete manually after verifying on GitHub: + git branch -D -- "" +``` + +## Error Handling + +### Not a git repository +``` +Error: Not inside a git repository. +``` + +### No origin remote +``` +Error: No 'origin' remote configured. +Run 'git remote -v' to see configured remotes. +``` + +### Network failure during fetch +- Normal mode: abort with error and recovery tip. +- `--dry-run`: warn and continue with stale data. + +### gh CLI not installed +Warn and fall back to git-only detection. Note that squash-merged branches won't be auto-detected. + +### Branch checked out in worktree +Skip with message naming the worktree path. + +## Examples + +```bash +# Preview which branches would be cleaned up +/branch-cleanup --dry-run + +# Interactive mode — shows candidates, asks for confirmation +/branch-cleanup + +# Non-interactive — delete without prompting +/branch-cleanup --yes +/branch-cleanup -y +``` + +## Notes + +- Uses `--` before branch names in all git commands to prevent branch names starting with `-` from being interpreted as flags. +- GitHub CLI detection handles squash-merged and rebase-merged PRs correctly (GitHub knows the PR was merged regardless of how commit hashes changed). +- Git gone-tracking detection is less reliable for squash/rebase merges since `git branch -d` requires the exact commits to be reachable from HEAD. +- Protected branches (`main`, `master`, current branch, worktree branches) are never deleted regardless of merge status. From a54aecbfdedab3df75dfd883adb48b463f587efd Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 1 Feb 2026 09:05:19 -0500 Subject: [PATCH 2/3] Address PR #122 review: fix git log pathspec, safer deletion, gh error handling - Remove `--` before branch names in git log commands (was causing pathspec interpretation instead of revision lookup) - Replace unconditional -D with two-pass approach: try -d first, fall back to -D only for gh-confirmed branches - Add error handling for gh pr list failures with fallback to git-only detection and appropriate warnings - Update Notes section to accurately describe -- usage scope Co-Authored-By: Claude Opus 4.5 --- .claude/commands/branch-cleanup.md | 45 ++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/.claude/commands/branch-cleanup.md b/.claude/commands/branch-cleanup.md index 05417f03..b7371e6f 100644 --- a/.claude/commands/branch-cleanup.md +++ b/.claude/commands/branch-cleanup.md @@ -96,8 +96,19 @@ For each remaining branch: gh pr list --state merged --head "" --json number,title --limit 1 ``` -- If the JSON array is non-empty, mark the branch as **gh-confirmed merged** and record the PR number and title. -- If the array is empty, the branch has no merged PR via this method — continue to 4b only if running the git-only fallback in parallel, otherwise skip. +- **If the command succeeds** (exit code 0) and returns valid JSON: + - If the JSON array is non-empty, mark the branch as **gh-confirmed merged** and record the PR number and title. + - If the array is empty, the branch has no merged PR via this method — continue to 4b only if running the git-only fallback in parallel, otherwise skip. +- **If the command fails** (non-zero exit code or invalid JSON output): + - If `gh` has failed for **every** branch checked so far (suggesting a systemic issue like auth failure), emit a single summary warning and disable `gh` for remaining branches: + ``` + Warning: GitHub CLI authentication failed. Falling back to git-only detection for all branches. + ``` + Set `GH_AVAILABLE=false` and fall back to step 4b for all remaining branches. + - Otherwise (isolated failure for one branch), emit a per-branch warning and fall back to step 4b for that branch: + ``` + Warning: gh pr list failed for branch '' — falling back to git-only detection. + ``` #### 4b. Git gone-tracking detection (fallback, or supplement when gh unavailable) @@ -118,9 +129,8 @@ For each candidate branch, collect: - **Last commit subject**: ```bash - git log -1 --format="%s" -- "" + git log -1 --format="%s" "" ``` - Note: use the branch ref, not `--`: `git log -1 --format="%s" ""` - **Relative age**: ```bash @@ -175,17 +185,22 @@ If the user chooses "Cancel", exit without deleting. ### 9. Delete Branches -For each candidate: +For each candidate, use a two-pass approach: -- **gh-confirmed merged**: use force delete (safe — GitHub confirms the work is preserved): - ```bash - git branch -D -- "" - ``` -- **Gone-tracking only** (no gh confirmation): use safe delete: - ```bash - git branch -d -- "" - ``` - If `-d` fails (common with squash/rebase merges where commit hashes differ), record the branch as a failure. +1. **Try safe delete first** (`-d`) for ALL candidates regardless of detection method: + ```bash + git branch -d -- "" + ``` + +2. **If `-d` succeeds**: the branch is fully merged into HEAD — no data loss possible. Record as deleted. + +3. **If `-d` fails AND the branch is gh-confirmed merged**: fall back to force delete. This is justified because GitHub confirms the PR's work is preserved on the remote; `-d` fails only because squash/rebase merges change commit hashes. + ```bash + git branch -D -- "" + ``` + Note: `-D` may drop local-only commits that were never pushed (e.g., work started after the PR merged). The merged PR's content is preserved, but any unpushed local changes on the branch will be lost. + +4. **If `-d` fails AND the branch is gone-tracking only** (no gh confirmation): record as a failure. Do not force-delete — there is no external confirmation that the work is preserved. ### 10. Report Results @@ -248,7 +263,7 @@ Skip with message naming the worktree path. ## Notes -- Uses `--` before branch names in all git commands to prevent branch names starting with `-` from being interpreted as flags. +- Uses `--` before branch names in `git branch` deletion commands to prevent branch names starting with `-` from being interpreted as flags. Do **not** use `--` before branch names in `git log`, where `--` separates revisions from pathspecs and would cause git to treat the branch name as a file path. - GitHub CLI detection handles squash-merged and rebase-merged PRs correctly (GitHub knows the PR was merged regardless of how commit hashes changed). - Git gone-tracking detection is less reliable for squash/rebase merges since `git branch -d` requires the exact commits to be reachable from HEAD. - Protected branches (`main`, `master`, current branch, worktree branches) are never deleted regardless of merge status. From ad53b0f2b86e734d70c167b4f76825662bc3c644 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 1 Feb 2026 09:14:02 -0500 Subject: [PATCH 3/3] Harden branch-cleanup: headRefOid check, credential safety, refs/heads/ for git log - Add headRefOid divergence check before force-deleting gh-confirmed branches to protect new local commits on reused branch names - Replace `git remote -v` with `git remote` to avoid leaking embedded credentials - Use `refs/heads/` in git log commands to prevent branch names starting with `-` from being interpreted as flags Co-Authored-By: Claude Opus 4.5 --- .claude/commands/branch-cleanup.md | 44 ++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/.claude/commands/branch-cleanup.md b/.claude/commands/branch-cleanup.md index b7371e6f..2fd80534 100644 --- a/.claude/commands/branch-cleanup.md +++ b/.claude/commands/branch-cleanup.md @@ -36,7 +36,7 @@ Parse by splitting `$ARGUMENTS` on whitespace. Recognise `--dry-run`, `--yes`, a ``` Error: No 'origin' remote configured. ``` - Then show `git remote -v` output so the user can see what remotes exist. + Then show `git remote` output (remote names only, no URLs — URLs may contain embedded credentials) so the user can see what remotes are configured. 3. **Check `gh` CLI availability**: ```bash @@ -93,11 +93,11 @@ For each remaining branch: #### 4a. GitHub CLI detection (if `GH_AVAILABLE`) ```bash -gh pr list --state merged --head "" --json number,title --limit 1 +gh pr list --state merged --head "" --json number,title,headRefOid --limit 1 ``` - **If the command succeeds** (exit code 0) and returns valid JSON: - - If the JSON array is non-empty, mark the branch as **gh-confirmed merged** and record the PR number and title. + - If the JSON array is non-empty, mark the branch as **gh-confirmed merged** and record the PR number, title, and `headRefOid` (the commit SHA at the branch tip when the PR was merged). - If the array is empty, the branch has no merged PR via this method — continue to 4b only if running the git-only fallback in parallel, otherwise skip. - **If the command fails** (non-zero exit code or invalid JSON output): - If `gh` has failed for **every** branch checked so far (suggesting a systemic issue like auth failure), emit a single summary warning and disable `gh` for remaining branches: @@ -129,12 +129,12 @@ For each candidate branch, collect: - **Last commit subject**: ```bash - git log -1 --format="%s" "" + git log -1 --format="%s" "refs/heads/" ``` - **Relative age**: ```bash - git log -1 --format="%ar" "" + git log -1 --format="%ar" "refs/heads/" ``` - **PR number and title** (from step 4a, if available) @@ -194,11 +194,18 @@ For each candidate, use a two-pass approach: 2. **If `-d` succeeds**: the branch is fully merged into HEAD — no data loss possible. Record as deleted. -3. **If `-d` fails AND the branch is gh-confirmed merged**: fall back to force delete. This is justified because GitHub confirms the PR's work is preserved on the remote; `-d` fails only because squash/rebase merges change commit hashes. +3. **If `-d` fails AND the branch is gh-confirmed merged**: compare the local branch tip to the stored `headRefOid` before force-deleting: ```bash - git branch -D -- "" + git rev-parse "" ``` - Note: `-D` may drop local-only commits that were never pushed (e.g., work started after the PR merged). The merged PR's content is preserved, but any unpushed local changes on the branch will be lost. + - **If the local tip matches `headRefOid`**: the branch has not changed since the PR was merged. Force-delete is safe: + ```bash + git branch -D -- "" + ``` + - **If the local tip does NOT match `headRefOid`**: the branch has new commits since PR #N was merged. Do **not** force-delete — record as a failure with a specific message: + ``` + Branch '' has new commits since PR # was merged. Skipping force-delete to protect unmerged work. + ``` 4. **If `-d` fails AND the branch is gone-tracking only** (no gh confirmation): record as a failure. Do not force-delete — there is no external confirmation that the work is preserved. @@ -214,9 +221,11 @@ Branch cleanup complete. Skipped: K branches (protected/checked-out) ``` -If any branches failed deletion: +If any branches failed deletion, group by failure reason: + +For **gone-tracking branches** that failed `-d` (likely squash-merged, no gh confirmation): ``` -The following branches could not be deleted with safe delete (likely squash-merged): +The following branches could not be safely deleted (likely squash-merged): @@ -224,6 +233,17 @@ To delete manually after verifying on GitHub: git branch -D -- "" ``` +For **gh-confirmed branches** that diverged from their merged PR tip: +``` +The following branches have new local commits since their PRs were merged: + + (PR # merged, but branch has new commits) + +Review the new commits before deleting: + git log ..refs/heads/ + git branch -D -- "" +``` + ## Error Handling ### Not a git repository @@ -234,7 +254,7 @@ Error: Not inside a git repository. ### No origin remote ``` Error: No 'origin' remote configured. -Run 'git remote -v' to see configured remotes. +Run 'git remote' to see configured remote names. ``` ### Network failure during fetch @@ -263,7 +283,7 @@ Skip with message naming the worktree path. ## Notes -- Uses `--` before branch names in `git branch` deletion commands to prevent branch names starting with `-` from being interpreted as flags. Do **not** use `--` before branch names in `git log`, where `--` separates revisions from pathspecs and would cause git to treat the branch name as a file path. +- Uses `--` before branch names in `git branch` deletion commands to prevent branch names starting with `-` from being interpreted as flags. Uses `refs/heads/` in `git log` commands to avoid both flag interpretation (branch names starting with `-`) and pathspec ambiguity (where `--` would cause git to treat the branch name as a file path). - GitHub CLI detection handles squash-merged and rebase-merged PRs correctly (GitHub knows the PR was merged regardless of how commit hashes changed). - Git gone-tracking detection is less reliable for squash/rebase merges since `git branch -d` requires the exact commits to be reachable from HEAD. - Protected branches (`main`, `master`, current branch, worktree branches) are never deleted regardless of merge status.