Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/Morrison-Lab/ai-config --skill clean-branches명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
SKILL.md 표시 중
| name | clean-branches |
| description | Clean dead or stale Git branches. |
| user-invocable | true |
| allowed-tools | ["Bash","Read","Edit","Write"] |
Audit branches in the current repo — both your local checkout and the remote. Delete dead ones, rebase stale ones, open MRs for orphaned work, and sweep up local-only stragglers — all without disrupting active sessions.
Prune in both places — they accumulate junk independently:
[gone]). The remote pass
alone won't catch a local branch whose remote is already gone, so there's a
dedicated local pass (step 8).| Category | Criteria | Action |
|---|---|---|
| Dead | Purely behind main (no unique commits ahead), no open MR, no linked issue, not created in the last 7 days | Delete |
| Stale | Has unique commits ahead of main but is behind main, no recent activity (>30 days), not actively being worked on | Rebase on main, open MR if none exists |
| Active | Has an open MR, linked issue, recent commits (<30 days), or a claim comment | Skip — don't touch |
| New | Created in the last 7 days | Skip — too fresh to judge |
| Local merged | Local branch fully merged into main, or whose PR merged (upstream [gone]) | Delete locally (git branch -d) — step 8 |
| Local-only unpushed | Local branch with unique commits, never pushed, no MR | Flag — ask before touching (step 8) |
The first four rows apply to remote branches (steps 1–7); the last two are the local pass (step 8). A branch can need both — e.g. delete the remote ref and the leftover local tracking branch.
git remote get-url origin
Determine GitHub (gh) vs GitLab (glab).
git fetch --prune origin
git branch -r --merged origin/main | grep -v 'origin/main\|origin/HEAD'
git branch -r --no-merged origin/main
First separate real remote branches from stray ref namespaces, or the
sweep's scope is wrong from its first command.
A one-off git fetch origin '+refs/pull/*/head:refs/remotes/pr/*' writes refs
that no configured refspec matches, so nothing updates them and --prune
never touches them --- but git branch -r counts them anyway.
git config --get-all remote.origin.fetch # what is tracked
git for-each-ref --format='%(refname)' | grep -v '^refs/remotes/origin/' # what is not
Anything outside the tracked namespace is local cruft rather than a branch:
git push origin --delete cannot touch it, and it belongs in a separate
git update-ref --stdin sweep, not in the plan below.
Write ref patterns with **, because a single * does not cross a slash.
git for-each-ref 'refs/remotes/origin/*' silently omits every slash-named
branch --- feat/, fix/, claude/ --- which is every branch anyone named
conventionally, including the ones carrying open PRs.
Nothing errors; the count merely comes back smaller and entirely plausible.
Note this is the opposite of pathspec matching, where * does cross a
slash; see memories/git-diffing.md, "A ref pattern is not a pathspec".
git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/**'
Give the enumeration a control before trusting a total: confirm a known slash-named branch appears in the output. A shortfall here is indistinguishable from a tidy repo.
For each remote branch (excluding main, HEAD, protected branches):
# Commits on branch not on main (ahead count)
git rev-list --count origin/main..origin/<branch>
# If 0 → branch is purely behind main (already merged or never diverged)
git log -1 --format='%ci' origin/<branch>
Skip if created/last-committed within the last 7 days (too new).
# GitLab
glab mr list --source-branch=<branch> 2>&1 | cat
# GitHub
gh pr list --head=<branch> --json number,title,state | cat # LIST_PRS
If an open MR exists → Active, skip.
Fetch every PR once and match locally, rather than querying per branch. A sweep of N branches costs N API round-trips this way, which is slow and rate-limit-prone at repo scale; one call answers for all of them, and it also supplies the merged and closed-unmerged states that b and f need.
gh pr list --state all --limit 2000 \
--json number,headRefName,state,mergedAt,url,title > /tmp/all-prs.json # LIST_PRS
Then index by headRefName and look each branch up in memory.
Raise --limit above the repo's total PR count and check the returned length
against it --- a silently truncated list marks live branches as having no PR,
which is the one error in this sweep that deletes something.
Look for branch naming patterns that reference issues:
fix/123-*, feat/123-*, issue-123-* → check if issue #123 is opengh pr view <N> --json comments \
-q '.comments[] | select(.body | test("hold off|paws off|back off"; "i"))
| select((.body | test("unclaim|released|PR is free|now mergeable"; "i")) | not)
| "\(.author.login): \(.body)"' # READ_PR_COMMENTS
If a claim comment exists within the last 24 hours → Active, skip.
This window is deliberately wider than
claim-pr's 2-hour claim expiration:
that rule decides who may start work, where the cost of over-respecting a
dead claim is a wait; this check gates branch deletion, which is
destructive, so it errs further toward keeping.
Check c only asks whether an open PR exists, so a branch whose PR closed unmerged falls straight through to a delete classification. That is right for an abandoned branch and wrong for a superseded one, because a closure rationale describes the PR's stated purpose, not an inventory of its diff. "Superseded by #260, all review findings addressed" is a claim about the feature; it says nothing about an unrelated fix that happened to share the branch.
The gap is easy to miss in exactly the case that matters, since the rationale is
usually accurate about the thing it names, and the leftover work is by
definition the part nobody was talking about.
Nothing else flags it either: git branch -d consults the upstream rather than
main (see the safety rules below), the superseding PR merged and closed the
issue, and the tracker looks clean.
So for any branch whose PR closed unmerged, compare the files rather than reading the closure comment:
gh pr list --head <branch> --state closed \
--json number,title,mergedAt,url | cat # LIST_PRS
gh pr diff <N> --name-only # DIFF_PR
git diff --stat origin/main...origin/<branch> # what is still unlanded
mergedAt: null on a closed PR is the trigger.
Then diff each file the branch touched against main;
anything still absent is unlanded work, and the branch is Stale, not Dead
--- rebase it and open an MR (steps 6--7), or file an issue for the salvageable
part and say so in the plan before deleting.
Weight the check by how far the branch's contents stray from its title: a branch
carrying commits unrelated to the PR that named it is the shape this catches.
rescue-closed is the deliberate, whole-tracker version of the same sweep; this
is the minimum owed before a deletion.
(2026-07-29: a bcs branch closed as superseded still carried an orthogonal
out-of-memory fix --- geepack::geeglm replaced by glm + sandwich::vcovCL
--- that the superseding privacy redesign never touched.
Deleting on the closure rationale would have discarded it silently;
filed instead as ucdavis/bcs#466.)
Once you have found unlanded work, the deliverable is a tracking issue --- not
keeping the branch.
On GitHub the branch is not the only copy: refs/pull/N/head is retained
permanently and still resolves after the head branch is deleted.
So the choice is not "delete or preserve", it is "record or lose track of",
which is a much easier call and lets the sweep finish.
Verify rather than assume, since it is one call per PR, then put the recovery command in the issue:
git ls-remote origin 'refs/pull/<N>/head' # should equal the branch tip
git fetch origin refs/pull/<N>/head
git checkout -b recover/<N> FETCH_HEAD
An issue naming what was unlanded, how much of it, and that command is strictly
more useful than a stale branch nobody will revisit --- and unlike the branch,
it appears in the tracker.
(2026-08-02, Morrison-Lab/ai-config: six closed-unmerged branches carried
work absent from main; every refs/pull/N/head resolved and matched its
branch tip exactly, so all six were deleted after the two substantial ones were
filed as #1062 and #1063.)
Before taking any action, present a table to the user:
## Branch Cleanup Plan
| Branch | Last commit | Status | Action |
|--------|-------------|--------|--------|
| `old-feature` | 2025-03-15 | Dead (merged) | 🗑️ Delete |
| `wip-refactor` | 2025-11-20 | Stale (45 days, no MR) | 🔄 Rebase + open MR |
| `fix/42-typo` | 2026-06-15 | Active (open MR !80) | ⏭️ Skip |
| `experiment` | 2026-06-12 | New (<7 days) | ⏭️ Skip |
Proceed? (or pick specific branches to act on)
Wait for user confirmation before proceeding. If user says "just go" or "do it", proceed with all proposed actions.
git push origin --delete <branch> # DELETE_REF
Also clean up local tracking branches:
git branch -d <local-tracking-branch> # if it exists locally
For each stale branch:
git checkout -B <branch> origin/<branch> # -B (not -b) force-resets if the branch already exists locally
git rebase origin/main
If rebase has conflicts:
resolve-conflicts skill —
consolidate both sides, don't blind-pick)If rebase succeeds:
git push --force-with-lease origin <branch> # PUSH
For stale branches that have no open MR after rebasing:
# GitLab — assign to the current glab user (override ASSIGNEE to assign someone else)
ASSIGNEE="$(glab api user 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)['username'])")"
glab mr create --source-branch=<branch> --target-branch=main \
--title "<inferred title from branch name>" \
--description "Orphaned branch rebased onto main. Review or close if no longer needed." \
${ASSIGNEE:+--assignee "$ASSIGNEE"}
# GitHub
gh pr create --head=<branch> --base=main \
--title "<inferred title>" \
--body "Orphaned branch rebased onto main. Review or close if no longer needed." # CREATE_PR
The remote pass doesn't touch branches that only exist in your checkout. Sweep those too. First refresh remote-tracking state so "merged" and "gone" are accurate:
git fetch --prune origin # marks deleted upstreams as [gone]
git branch --show-current # never delete the branch you're on
The --prune is a prerequisite, not a refresh --- without it step b finds
nothing and reports success. [gone] is produced by pruning, not by the
branch being deleted upstream: until a prune runs, %(upstream:track) is
empty for exactly the branches b exists to find, so its grep matches zero
rows and the sweep says there is nothing to clean.
The "nothing found" and "never ran" paths print the same thing,
so never take a zero-row result as clean
unless this fetch actually ran in this sweep.
Recommend the standing setting once, since it makes every other tool's view
correct too --- but keep the explicit --prune above regardless, so the sweep
does not depend on the user's config:
git config --global fetch.prune true # or per-remote:
git config --global remote.origin.prune true
That setting only prunes refs/remotes/origin/*.
It never deletes a local branch, so it replaces none of the steps below.
Classify each local branch (excluding main/master/protected and the
current branch):
git branch --merged origin/main | grep -vE '^\s*\*|^\s*main\s*$|^\s*master\s*$'
# Line-anchored so only the literal `main`/`master` lines (and the current `*`
# branch) are excluded — a branch like `maintain-docs` or `feature-main-menu`
# is NOT silently filtered out.
# Compare against origin/main (just fetched), NOT local `main` — your local main
# may be behind, which would hide branches that are actually merged.
git branch -d <branch> # -d refuses if NOT actually merged — a safety net
-d (never -D) is deliberate: if git refuses, the branch has unmerged
commits — treat it as stale, not dead (see b).
A branch whose remote was deleted shows [gone]:
git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads \
| grep '\[gone\]'
For each, confirm the PR/MR actually merged before deleting (never assume):
# GitHub
gh pr list --head <branch> --state merged --json number,mergedAt | cat # LIST_PRS
# GitLab
glab mr list --source-branch=<branch> --state merged 2>&1 | cat
git branch -D <branch> is acceptable here (the work landed via
squash/rebase merge, so -d may not see it as merged). Confirm the merge
first.Read the PR, not local ancestry, to decide whether the work landed. In a
repo that squash-merges --- ucdavis/bcs and Morrison-Lab/ai-config both do
--- a branch whose work is already on main still reports a non-zero
ahead-count, is not an ancestor of main, is absent from
git branch --merged, and is refused by git branch -d.
All four say "unmerged" about a branch that merged,
because the squash commit is a different commit.
So treat a -d refusal as unproven,
never as evidence the branch holds unique local work ---
that is what the merged-PR lookup above is for,
and why -D is the right tool once it comes back positive.
The converse still holds and is what keeps this safe: no merged PR and unique commits means the work may exist nowhere else, so it is never deleted without confirmation. When the PR lookup is inconclusive, check content rather than ancestry, since content survives a squash:
git show origin/main:<path> | grep <something-the-branch-added> # did it land?
git for-each-ref --format='%(refname:short) %(upstream)' refs/heads \
| awk '$2=="" {print $1}' # local branches with no upstream at all
Report these as "local-only, unpushed" and ask before doing anything — they may be in-progress work that hasn't been pushed yet. Don't delete without confirmation.
Apply the same dry-run discipline to local deletions as step 4 does for remote branches — no silent local deletions. If you're doing a full local+remote sweep, fold these local rows into the step-4 plan and present them together; if you're running the local pass on its own, present a standalone local plan here and wait for confirmation before deleting anything.
A standing daytb grant lifts this confirm step for the safe cases --- deleting a merged local branch is one of the local-git housekeeping actions that grant explicitly covers.
The skill's own safety preconditions are unchanged, and anything carrying commits reachable from no remote still asks.
Print a summary covering both local and remote:
## Branch Cleanup Complete — <timestamp>
### Deleted — remote (dead)
- `old-feature` (last commit 2025-03-15, merged into main)
### Deleted — local (merged / upstream gone)
- `add-wrap-up-skill` (PR #26 merged; local straggler)
- `ums-session-learnings` (merged into main)
### Rebased + MR opened (stale)
- `wip-refactor` → [!85](url) (rebased, 3 commits ahead)
### Skipped (active/new)
- `fix/42-typo` — open MR !80
- `experiment` — created 2 days ago
### Flagged — local-only, unpushed (left alone)
- `scratch-idea` — 4 unpushed commits, no MR; your call
### Failed (conflicts)
- `ancient-branch` — rebase conflicts, needs manual resolution
gh pr list --state merged names a PR for the branch, residual
differences against main are the branch's pre-review draft versus the
post-review text that actually merged, and mean nothing about safety.
Measuring anyway invents doubt about the one case that is already certain:
in a 2026-08-02 sweep, 47 of 50 local branches had merged PRs and several
scored under 90% on a line-presence check purely because review had reworded
them.refs/pull/N/head is retained permanently and resolves after the head
branch is deleted, so "delete the branch" and "lose the work" are different
events on GitHub.
This does not weaken the local-only rule below --- a branch that was never
pushed has no PR ref behind it, and that is exactly the case that needs
confirmation.main, master, develop, or any protected branch.git branch --show-current).git branch -d over -D for local deletions — -d refuses unless
the branch is merged, which catches "I thought this landed but it didn't."
Only use -D after confirming the PR merged (squash/rebase merges can leave a
local branch that -d won't recognize as merged).-d as proof the work reached main.
Per git-branch(1), the branch must be fully merged "in its upstream branch,
or in HEAD if no upstream was set".
So a branch still tracking a live origin/<name> passes on the upstream
check alone, printing warning: deleting branch 'X' that has been merged to 'refs/remotes/origin/X', but not yet merged to HEAD.
Only a [gone] upstream falls back to the HEAD comparison.
Both outcomes are routine in one sweep (18 / 11 across 29 branches
in one 2026-07-29 run), so whether sufficed or was required is not
a classification signal --- step 3 is.sync-pr-branch — used internally when rebasing stale branches
claim-pr — checked to avoid touching claimed branches
ardi — user may want to ARDI the newly opened MRs afterward
clean-worktrees / cw — the worktree counterpart. This skill sweeps
branches; that one sweeps the worktrees a branch is checked out into. Run
both so neither a dead worktree nor an orphaned branch lingers.
clean-git --- the combined sweep.
Runs clean-worktrees first, then this skill,
because a branch held by a worktree cannot be deleted.
-d-D-d-D