bundles/github/skills/git-cleanup/SKILL.md
Clean up the git working state — verify branches are provably merged into the trunk (default branch) via the squash-aware GitHub PR merge oracle, then prune merged local and remote feature branches and stale git worktrees. Squash-merge aware — uses GitHub PR merge state as the merge oracle, not commit ancestry. Use when the user asks to clean up branches or worktrees, prune what is already merged, run /cleanup, or confirm nothing stale was left behind before pruning.
npx skillsauth add shipshitdev/library git-cleanupInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Confirm each branch's work has reached the trunk (default branch), then prune the feature branches and git worktrees that are no longer needed. Verification is a hard gate: never prune until each branch's work is proven to have reached the trunk and no in-flight work is stranded.
This skill is standalone and manually triggerable (exposed as /cleanup). It does
not promote code (use release-pr-gates for that) and does not deploy (use
deploy). It runs after merges have landed and tidies up.
Commit ancestry is NOT a reliable merge signal. GitHub's default merge mode is
squash, which collapses a branch into a single new commit on the base. After a
squash merge the branch tip is not an ancestor of the base, so git branch --merged / --no-merged and A..B ranges all report a fully-merged branch as
unmerged. Rebase-merges have the same property.
Consequence if you trust ancestry on a squash repo:
git branch -d refuses every local merged branch.Therefore this skill's merge oracle is GitHub PR state first, ancestry second:
A branch's work is IN the production branch iff ANY of:
MERGED and that PR's mergeCommit is an ancestor of the
production branch (covers squash, rebase, and merge-commit), ORgit cherry),
or the branch's cumulative diff is patch-identical to a single trunk commit
added since the merge base (covers local retarget/rebase copies whose name no
longer matches a PR head).Only branches that satisfy this are prunable. Everything else is reported, never deleted.
A matching commit subject is never proof of a merge. Subjects like fix: lint
or chore: bump deps recur across unrelated branches, so subject equality would
classify genuinely unmerged work as prunable and hand it to git branch -D. Worse,
subject matching barely helps in the case it was meant for: a squash merge
rewrites the branch's several subjects into one PR title, so they no longer match
anyway. Rule 3 therefore never compares subjects — the proof is always
git patch-id, scanning the trunk commits added since the merge base. The scan is
bounded (--max-count=500); if the squashed commit falls outside that window the
branch is reported unproven, never deleted.
Inputs:
gh repo view --json defaultBranchRef if not suppliedverify (gate only), dry-run (default, plan only), or prune (execute after confirmation)branches, worktrees, or all resource types (default)Outputs:
Creates/Modifies:
git worktree prunegit remote prune)External Side Effects:
git push origin --deleteConfirmation Required:
Delegates To:
release-pr-gates when a branch is NOT yet merged into the trunk and the user wants to open or land the PR firstgh-fix-ci when a PR targeting the trunk is still open with failing checksgit-safety when a branch about to be pruned may contain secrets in history worth scrubbing firstDo not use this skill to promote code or to delete unmerged work. It only removes what is provably in the production branch.
Protected branches are never deleted:
master main (trunk / default branch) + the currently checked-out branch + HEAD
Hard rules:
git branch --merged alone. A branch is prunable only when its
work is proven to be in the production branch.dry-run: print the exact plan and stop. Deletion only
happens in prune mode after the user confirms the printed plan.git branch -D (force local delete) is used ONLY for a local branch the oracle
has proven is in the production branch — squash/rebase merges legitimately
require it because -d cannot see them. For any branch NOT proven-in-prod,
force flags are never used; report it instead.git worktree remove --force and deleting a remote branch the oracle has NOT
proven-in-prod are never done automatically.git/gh/jq in the agent shell. If a command is missing, restore
PATH in that same shell (/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin).
Never write a helper script to disk for this skill.command -v git >/dev/null || export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
gh auth status -h github.com
git status -sb
git remote -v
git fetch --all --prune
gh repo view --json nameWithOwner,defaultBranchRef --jq '.defaultBranchRef.name'
Determine the trunk from the repo metadata:
gh repo view --json defaultBranchRef --jq .defaultBranchRef.name. Never hardcode master or main.Look up the latest PR per candidate head. If a snapshot file is needed, write
it under the current repo's .tmp/ (create it). Never /tmp or /private/tmp.
Do not cap the search at 1000 PRs.
REPO_ROOT=$(git rev-parse --show-toplevel)
mkdir -p "$REPO_ROOT/.tmp"
latest_pr_for_head() { # arg: branch name without origin/
gh pr list --head "$1" --state all --limit 1 \
--json number,headRefName,baseRefName,state,mergedAt,mergeCommit
}
After a squash merge, GitHub often deletes the remote head. Remotes can already
be just origin/<trunk> while leftover local branches and worktrees remain.
Classify remotes, locals, and extra worktrees. A remote-only pass is not enough.
For each candidate (remote branch, local branch, or extra worktree HEAD), verify
that its work has reached the trunk. Ancestry is the first signal, but squash
merges require corroboration — the merged PR for that head, or failing that,
patch identity against the trunk (squash_artifact in 2b).
TRUNK=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)
PROD="origin/${TRUNK}"
# Commits on a ref not in the trunk
git log --oneline "$PROD".."<ref>"
# PR that landed this head name
latest_pr_for_head "<branch>"
Interpreting a non-empty ahead range:
*-onto-<trunk>, pr-N-rebase, detached worktree HEADs) get
classified when their name no longer matches a PR head.Run the Merge Oracle over every non-protected remote and local branch, plus
each extra worktree. Do NOT use git branch --merged / -r --no-merged.
TRUNK=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)
PROD="origin/${TRUNK}"
CURRENT="$(git symbolic-ref --quiet --short HEAD || echo)"
PROTECT="${TRUNK}|${CURRENT:-__none__}|HEAD"
# Proves a ref's changes are already in the trunk by PATCH IDENTITY.
# Never matches on commit subjects: unrelated branches reuse subjects like
# "fix: lint", and a subject match would send live work to `git branch -D`.
squash_artifact() { # arg: git ref. 0 = the ref's changes are provably in trunk
local ref="$1" base mine c pid
base=$(git merge-base "$PROD" "$ref" 2>/dev/null) || return 1
# Nothing ahead of the merge base => this rule has nothing to prove.
[ -n "$(git log --format=%H "$base".."$ref" 2>/dev/null)" ] || return 1
# (a) Per-commit equivalence: every ahead commit has a patch-identical twin
# upstream. `git cherry` marks those '-'; a surviving '+' means real work.
git cherry "$PROD" "$ref" 2>/dev/null | grep -q '^+' || return 0
# (b) Squash equivalence: the ref's cumulative diff is patch-identical to the
# patch a single trunk commit introduced. Scan only trunk commits added
# since the merge base — the squash commit can only live in that window.
mine=$(git diff "$base".."$ref" | git patch-id --stable | awk '{print $1}')
[ -n "$mine" ] || return 1
for c in $(git rev-list --max-count=500 "$base".."$PROD" 2>/dev/null); do
pid=$(git show "$c" | git patch-id --stable | awk '{print $1}')
[ "$pid" = "$mine" ] && return 0
done
return 1 # unproven => caller reports it, never deletes it
}
classify_ref() { # args: head-name, git-ref to test for ancestry
local b="$1" ref="$2" rec st mc base num
rec=$(latest_pr_for_head "$b")
rec=$(jq -c 'if type=="array" then .[0] else . end' <<<"${rec:-null}")
if [ -z "$rec" ] || [ "$rec" = "null" ]; then
git merge-base --is-ancestor "$ref" "$PROD" 2>/dev/null \
&& { echo "PRUNABLE_NO_PR_FF"; return; }
squash_artifact "$ref" \
&& { echo "PRUNABLE_SQUASH_ARTIFACT"; return; }
echo "STRANDED_NO_PR"
return
fi
st=$(jq -r '.state' <<<"$rec")
mc=$(jq -r '.mergeCommit.oid // empty' <<<"$rec")
base=$(jq -r '.baseRefName' <<<"$rec")
num=$(jq -r '.number' <<<"$rec")
case "$st" in
OPEN) echo "IN_FLIGHT_OPEN_PR(#$num->$base)";;
CLOSED) git merge-base --is-ancestor "$ref" "$PROD" 2>/dev/null \
&& echo "PRUNABLE_CLOSED_PR_IN_PROD(#$num)" \
|| echo "STRANDED_CLOSED_UNMERGED(#$num)";;
MERGED)
if [ -n "$mc" ] && git merge-base --is-ancestor "$mc" "$PROD" 2>/dev/null; then
echo "PRUNABLE_IN_TRUNK(#$num)"
elif git merge-base --is-ancestor "$ref" "$PROD" 2>/dev/null; then
echo "PRUNABLE_IN_TRUNK(#$num)"
elif squash_artifact "$ref"; then
echo "PRUNABLE_SQUASH_ARTIFACT(#$num)"
else
echo "MERGED_NOT_YET_IN_TRUNK(#$num->$base)"
fi;;
esac
}
git branch -r --format '%(refname:short)' | grep -v -- '->' | sed 's#^origin/##' \
| grep -vxE "origin|${TRUNK}|HEAD" \
| while read -r b; do printf 'REMOTE %-50s %s\n' "$b" "$(classify_ref "$b" "refs/remotes/origin/$b")"; done
git branch --format '%(refname:short)' | grep -vxE "$PROTECT" \
| while read -r b; do printf 'LOCAL %-50s %s\n' "$b" "$(classify_ref "$b" "$b")"; done
For each extra worktree from git worktree list --porcelain, classify its
checked-out branch the same way. Detached HEAD: use PRUNABLE_SQUASH_ARTIFACT
when squash_artifact HEAD succeeds at that path.
Buckets and what they mean:
PRUNABLE_* — work is in the trunk. Safe to prune.MERGED_NOT_YET_IN_TRUNK — PR was merged into an intermediate branch that has not
yet been merged into the trunk. NOT prunable yet; this is a real "not yet in trunk"
signal for that branch. Report it.IN_FLIGHT_OPEN_PR — open PR. In progress. Skip, never prune.STRANDED_* — no merged PR and not in the trunk. Genuinely forgotten work.
Report loudly, never prune.Gate outcome:
release-pr-gates.STRANDED_* branch => report as a warning; the user decides whether it was
meant to ship. This is the "nothing is stale" guarantee.The prunable set is exactly the refs the oracle tagged PRUNABLE_* in Phase 2b.
Print three lists from that classification:
PRUNABLE_* (annotate needs -D for squash/rebase).PRUNABLE_*.PRUNABLE_* and whose git -C <path> status --porcelain
is empty. Dirty => SKIP. Unmerged => SKIP. Upstream gone and proven-in-prod =>
safe to remove.Plus a skipped list with reasons (MERGED_NOT_YET_IN_TRUNK, IN_FLIGHT_OPEN_PR,
STRANDED_*, dirty worktree). Then stop and ask for confirmation. In dry-run
(default) and verify modes, end here.
prune Mode, After Confirmation)Only after the user confirms the printed plan. Worktrees first: a branch checked out in a worktree cannot be deleted.
# Worktrees flagged safe. Never --force; refuses on dirty.
git worktree remove <path> ...
git worktree prune
# Local branches proven-in-prod. Try -d first; fall back to -D ONLY when the
# oracle proved the branch is in prod (squash/rebase merges require it).
for b in <prunable-local-branches>; do
git branch -d "$b" 2>/dev/null || git branch -D "$b"
done
# Remote branches proven-in-prod
git push origin --delete <branch> ...
# Drop stale remote-tracking refs
git remote prune origin
git fetch --all --prune
Rules during execution:
-D is permitted ONLY for branches the Phase-3 oracle tagged PRUNABLE_*.
Never blind-force a branch that is not proven-in-prod.git worktree remove refuses (dirty/locked), do not --force. Report and skip.git-cleanup verify — Phase 1 + 2 only. Report trunk verification status and the branch classification. No plan, no deletion.git-cleanup or git-cleanup dry-run — Phases 1-3. Verify, then print the prune plan. No deletion. (Default.)git-cleanup prune — Phases 1-4. Verify, print plan, confirm, then delete.If the caller scopes the cleanup (branches, worktrees, "local branches only",
"skip remote"), honor it: still run verification, but restrict the plan and
execution to the requested resource types. The default scope is everything —
branches and worktrees.
Report:
STRANDED_*), if anyMERGED_NOT_YET_IN_TRUNK), if any-D was needed)development
Coordinates a weekly engineering review of board accuracy, recent code changes, operational health, and scoped cleanup. Use for a recurring repository health review or a review of the last several days.
testing
Audits project board configuration and prepares explicitly requested setup, copy, or normalization changes while preserving the existing workflow and provider boundaries. Use when inspecting a board's fields, columns, scope, or configuration.
testing
Reconciles a project board with current work and delivery evidence, reports incomplete coverage and metadata gaps, and applies only approved provider-supported field changes. Use when auditing board drift, reviewing blocked work, or assessing upcoming delivery.
development
Walk through how a subsystem works. Use for "how does X work", code walkthroughs before changing something, and placement or ownership questions. Explains architecture, runtime flow, and onboarding mental models. Can critique architecture. Use why for motivation.