Skills git-cleanup
Safely analyzes and cleans up local git branches and worktrees by categorizing them as merged, squash-merged, superseded, or active work.
git clone https://github.com/trailofbits/skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/trailofbits/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/git-cleanup/skills/git-cleanup" ~/.claude/skills/trailofbits-skills-git-cleanup && rm -rf "$T"
plugins/git-cleanup/skills/git-cleanup/SKILL.mdGit Cleanup
Safely clean up accumulated git worktrees and local branches by categorizing them into: safely deletable (merged), potentially related (similar themes), and active work (keep).
When to Use
- When the user has accumulated many local branches and worktrees
- When branches have been merged but not cleaned up locally
- When remote branches have been deleted but local tracking branches remain
When NOT to Use
- Do not use for remote branch management (this is local cleanup only)
- Do not use for repository maintenance tasks like gc or prune
- Not designed for headless or non-interactive automation (requires user confirmations at two gates)
Core Principle: SAFETY FIRST
Never delete anything without explicit user confirmation. This skill uses a gated workflow where users must approve each step before any destructive action.
Critical Implementation Notes
Squash-Merged Branches Require Force Delete
IMPORTANT:
git branch -d will ALWAYS fail for squash-merged branches because git cannot detect that the work was incorporated. This is expected behavior, not an error.
When you identify a branch as squash-merged:
- Plan to use
(force delete) from the startgit branch -D - Do NOT try
first and then ask again forgit branch -d
- this wastes user confirmations-D - In the confirmation step, show
for squash-merged branchesgit branch -D
Group Related Branches BEFORE Categorization
MANDATORY: Before categorizing individual branches, group them by name prefix:
# Extract common prefixes from branch names # e.g., feature/auth-*, feature/api-*, fix/login-*
Branches sharing a prefix (e.g.,
feature/api, feature/api-v2, feature/api-refactor) are almost certainly related iterations. Analyze them as a group:
- Find the oldest and newest by commit date
- Check if newer branches contain commits from older ones
- Check which PRs merged work from each
- Determine if older branches are superseded
Present related branches together with a clear recommendation, not scattered across categories.
Thorough PR History Investigation
Don't rely on simple keyword matching. For
[gone] branches:
# 1. Get the branch's commits that aren't in default branch git log --oneline "$default_branch".."$branch" # 2. Search default branch for PRs that incorporated this work # Search by: branch name, commit message keywords, PR numbers git log --oneline "$default_branch" | grep -iE "(branch-name|keyword|#[0-9]+)" # 3. For related branch groups, trace which PRs merged which work git log --oneline "$default_branch" | grep -iE "(#[0-9]+)" | head -20
Workflow
Phase 1: Comprehensive Analysis
Gather ALL information upfront before any categorization:
# Get default branch name default_branch=$(git symbolic-ref refs/remotes/origin/HEAD \ 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main") # Protected branches - never analyze or delete protected='^(main|master|develop|release/.*)$' # List all local branches with tracking info git branch -vv # List all worktrees git worktree list # Fetch and prune to sync remote state git fetch --prune # Get merged branches (into default branch) git branch --merged "$default_branch" # Get recent PR merge history (squash-merge detection) git log --oneline "$default_branch" | grep -iE "#[0-9]+" | head -30 # For EACH non-protected branch, get unique commits and sync status for branch in $(git branch --format='%(refname:short)' \ | grep -vE "$protected"); do echo "=== $branch ===" echo "Commits not in $default_branch:" git log --oneline "$default_branch".."$branch" 2>/dev/null \ | head -5 echo "Commits not pushed to remote:" git log --oneline "origin/$branch".."$branch" 2>/dev/null \ | head -5 || echo "(no remote tracking)" done
Note on branch names: Git branch names can contain characters that break shell expansion. Always quote
"$branch" in commands.
Phase 2: Group Related Branches
Do this BEFORE individual categorization.
Identify branch groups by shared prefixes:
# List branches and extract prefixes git branch --format='%(refname:short)' | sed 's/-[^-]*$//' | sort | uniq -c | sort -rn
For each group with 2+ branches:
- Compare commit histories - Which branches contain commits from others?
- Find merge evidence - Which PRs incorporated work from this group?
- Identify the "final" branch - Usually the most recent or most complete
- Mark superseded branches - Older iterations whose work is in main or in a newer branch
SUPERSEDED requires evidence, not just shared prefix:
- A PR merged the work into main, OR
- A newer branch contains all commits from the older branch
- Name prefix alone is NOT sufficient — similarly named branches may contain independent work
Example analysis for
feature/api-* branches:
### Related Branch Group: feature/api-* | Branch | Commits | PR Merged | Status | |--------|---------|-----------|--------| | feature/api | 12 | #29 (initial API) | Superseded - work in main | | feature/api-v2 | 8 | #45 (API improvements) | Superseded - work in main | | feature/api-refactor | 5 | #67 (refactor) | Superseded - work in main | | feature/api-final | 4 | None found | Superseded by above PRs | **Recommendation:** All 4 branches can be deleted - work incorporated via PRs #29, #45, #67
Phase 3: Categorize Remaining Branches
For branches NOT in a related group, categorize individually:
Is branch merged into default branch? ├─ YES → SAFE_TO_DELETE (use -d) └─ NO → Is tracking a remote? ├─ YES → Remote deleted? ([gone]) │ ├─ YES → Was work squash-merged? (check main for PR) │ │ ├─ YES → SQUASH_MERGED (use -D) │ │ └─ NO → REMOTE_GONE (needs review) │ └─ NO → Local ahead of remote? (check: git log origin/<branch>..<branch>) │ ├─ YES (has output) → UNPUSHED_WORK (keep) │ └─ NO (empty output) → SYNCED_WITH_REMOTE (keep) └─ NO → Has unique commits? ├─ YES → LOCAL_WORK (keep) └─ NO → SAFE_TO_DELETE (use -d)
Category definitions:
| Category | Meaning | Delete Command |
|---|---|---|
| SAFE_TO_DELETE | Merged into default branch | |
| SQUASH_MERGED | Work incorporated via squash merge | |
| SUPERSEDED | Part of a group, work verified in main via PR or in newer branch | |
| REMOTE_GONE | Remote deleted, work NOT found in main | Review needed |
| UNPUSHED_WORK | Has commits not pushed to remote | Keep |
| LOCAL_WORK | Untracked branch with unique commits | Keep |
| SYNCED_WITH_REMOTE | Up to date with remote | Keep |
Phase 4: Dirty State Detection
Check ALL worktrees and current directory for uncommitted changes:
# For each worktree path git -C <worktree-path> status --porcelain # For current directory git status --porcelain
Display warnings prominently:
WARNING: ../proj-auth has uncommitted changes: M src/auth.js ?? new-file.txt These changes will be LOST if you remove this worktree.
GATE 1: Present Complete Analysis
Present everything in ONE comprehensive view. Group related branches together:
## Git Cleanup Analysis ### Related Branch Groups **Group: feature/api-* (4 branches)** | Branch | Status | Evidence | |--------|--------|----------| | feature/api | Superseded | Work merged in PR #29 | | feature/api-v2 | Superseded | Work merged in PR #45 | | feature/api-refactor | Superseded | Work merged in PR #67 | | feature/api-final | Superseded | Older iteration, diverged | Recommendation: Delete all 4 (work is in main) --- ### Individual Branches **Safe to Delete (merged with -d)** | Branch | Merged Into | |--------|-------------| | fix/typo | main | **Safe to Delete (squash-merged, requires -D)** | Branch | Merged As | |--------|-----------| | feature/login | PR #42 | **Needs Review ([gone] remotes, no PR found)** | Branch | Last Commit | |--------|-------------| | experiment/old | abc1234 "WIP something" | **Keep (active work)** | Branch | Status | |--------|--------| | wip/new-feature | 5 unpushed commits | ### Worktrees | Path | Branch | Status | |------|--------|--------| | ../proj-auth | feature/auth | STALE (merged) | --- **Summary:** - 4 related branches (feature/api-*) - recommend delete all - 1 merged branch - safe to delete - 1 squash-merged branch - safe to delete - 1 needs review - 1 to keep Which would you like to clean up?
Use AskUserQuestion with clear options:
- Delete all recommended (groups + merged + squash-merged)
- Delete specific groups/categories
- Let me pick individual branches
Do not proceed until user responds.
GATE 2: Final Confirmation with Exact Commands
Show the EXACT commands that will run, with correct flags:
I will execute: # Merged branches (safe delete) git branch -d fix/typo # Squash-merged branches (force delete - work is in main via PRs) git branch -D feature/login git branch -D feature/api git branch -D feature/api-v2 git branch -D feature/api-refactor git branch -D feature/api-final # Worktrees git worktree remove ../proj-auth Confirm? (yes/no)
IMPORTANT: This is the ONLY confirmation needed for deletion. Do not add extra confirmations if
-D is required.
Phase 5: Execute
Run each deletion as a separate command so partial failures don't block remaining deletions. Report the result of each:
git branch -d fix/typo git branch -D feature/login git branch -D feature/api git branch -D feature/api-v2 git branch -D feature/api-refactor git branch -D feature/api-final git worktree remove ../proj-auth
If a deletion fails, report the error and continue with remaining deletions.
Phase 6: Report
## Cleanup Complete ### Deleted - fix/typo - feature/login - feature/api - feature/api-v2 - feature/api-refactor - feature/api-final - Worktree: ../proj-auth ### Remaining (4 branches) | Branch | Status | |--------|--------| | main | current | | wip/new-feature | active work | | experiment/old | needs review |
Safety Rules
- Never invoke automatically - Only run when user explicitly uses
/git-cleanup - Two confirmation gates only - Analysis review, then deletion confirmation
- Use correct delete command -
for merged,-d
for squash-merged/superseded-D - Never touch protected branches - main, master, develop, release/* (filtered programmatically)
- Block dirty worktree removal - Refuse without explicit data loss acknowledgment
- Group related branches - Don't scatter them across categories
Rationalizations to Reject
These are common shortcuts that lead to data loss. Reject them:
| Rationalization | Why It's Wrong |
|---|---|
| "The branch is old, it's probably safe to delete" | Age doesn't indicate merge status. Old branches may contain unmerged work. |
| "I can recover from reflog if needed" | Reflog entries expire. Users often don't know how to use reflog. Don't rely on it as a safety net. |
| "It's just a local branch, nothing important" | Local branches may contain the only copy of work not pushed anywhere. |
| "The PR was merged, so the branch is safe" | Squash merges don't preserve branch history. Verify the specific commits were incorporated. |
"I'll just delete all the branches" | only means the remote was deleted. The local branch may have unpushed commits. |
| "The user seems to want everything deleted" | Always present analysis first. Let the user choose what to delete. |
| "The branch has commits not in main, so it has unpushed work" | "Not in main" ≠ "not pushed". A branch can be synced with its remote but not merged to main. Always check . |