Claude Code: Daily Merged-worktree Cleanup
A daily job that removes the git worktrees and local branches whose PR is already
merged on GitHub. Agent sessions create a worktree per task and almost never clean
up after themselves, so a busy checkout of cred-platform-ts collects dozens of
them, each a full copy of the monorepo. It is one bash script plus a launchd job,
both in your personal setup. The last section is a
prompt you can paste to your own agent to set it up in a few minutes.
This is not a skill
Unlike the entries on Global Skills, nothing here is invoked by
name and no agent is involved at run time. launchd runs a plain script once a day.
Why it exists
git worktree list only knows that a worktree exists, not whether its work has
landed. Deciding that by hand means opening each branch's PR, and nobody does that
for 40 branches, so the worktrees stay. Each one costs disk (a single cleanup pass
over 16 repos once freed 43 GB) and makes git branch unreadable.
Two shortcuts look right and are wrong, which is why the script asks GitHub instead:
git branch --merged developmisses squash merges. A squash merge creates a new commit, so the branch never looks merged. In one measured pass, 83 of 98 merged branches looked unmerged this way.- "No changes since N days" deletes live work. A branch waiting for review is idle and unmerged at the same time.
How it works
| Piece | Path | Job |
|---|---|---|
| Script | ~/.claude/bin/git-cleanup-merged |
Classifies every local branch and removes what is safe. Dry run unless --apply |
| Job | ~/Library/LaunchAgents/com.cred.git-cleanup-merged.plist |
Runs the script with --apply every day at 09:15 |
| Log | ~/.claude/logs/git-cleanup-merged.log |
One block per run: what was removed, what was kept and why |
The script makes one bulk gh pr list --state all call for the last 1000 PRs (plus
one small call per local branch that is not among them), then removes a worktree and deletes its local branch only when all of these hold:
- The branch's PR is MERGED. A branch with no PR, or only a closed-unmerged PR, is never touched.
- The local branch head is the PR's merged head, or every commit past it is already
byte-identical in
origin/develop(it landed through a follow-up PR). - The worktree has no untracked files and no uncommitted changes to tracked files.
The one exception is a dirty lockfile alone (
bun.lock,package-lock.json,yarn.lock), which is thrown away with the worktree. - No git command has touched the worktree for 12 hours (
--min-idle-hours, read from the worktree's index file). This is only a proxy: an agent that edits files without running git leaves the index alone. Rule 3 is what actually protects unsaved work. - It is not the current branch and not
develop,main,masterorstaging.
Everything it keeps is listed with the reason, for example:
--- KEPT (3) ---
worktree-agent-a5c98c3749d2ae118 NO-PR no pull request found
com-51698-partial-pick #31012 MERGED worktree has uncommitted work: apps/api-commercial/...
com-52001-some-fix #31100 OPEN not merged
A "kept" branch is the script refusing to guess. Look at it yourself: uncommitted work in a merged branch is usually regenerated files, but it can be a fix that never got pushed.
Limits
- One repository per job. The job runs against the path you give it with
-C. For a second repository, add a second job with its own label. - It assumes
developis the default branch for rule 2. In a repository whose default ismain, a branch with commits past its merged head is kept rather than removed, which is safe but means you clean those few by hand. - It needs
ghlogged in. If the PR list cannot be read, the run aborts without changing anything. - A merged branch whose worktree holds untracked files is kept, every day, until you commit or delete those files. That is deliberate: nothing about a merged PR says they were meant to be thrown away.
- Gitignored files are deleted with the worktree, and the log does not list
them.
git statusdoes not see them, so rule 3 cannot protect them. A per-worktree.env.local, test credentials or notes in an ignored folder go with it. Keep anything you need from a worktree outside it, or in the main checkout. - The log is appended to on every run and never trimmed. It grows by a few KB a day; truncate it now and then.
Use it by hand
~/.claude/bin/git-cleanup-merged -C ~/Developer/cred-platform-ts # dry run, changes nothing
~/.claude/bin/git-cleanup-merged -C ~/Developer/cred-platform-ts --apply # remove
tail -40 ~/.claude/logs/git-cleanup-merged.log # last scheduled run
launchctl bootout gui/$(id -u)/com.cred.git-cleanup-merged # turn the job off
Always look at a dry run before your first --apply.
Install it with your agent
Point Claude Code at this page, or paste the prompt below. Requires macOS, git,
python3 and the GitHub CLI (gh), logged in. No tokens are stored: the script uses
your existing gh login.
Read the "Claude Code: Daily Merged-worktree Cleanup" wiki page and set it up for me.
Everything goes under ~/.claude/ and ~/Library/LaunchAgents/, never into a repository.
1. Create ~/.claude/bin/git-cleanup-merged from File 1 and chmod +x it.
Create ~/.claude/logs/ if missing.
2. Ask me which repository to clean (default ~/Developer/cred-platform-ts). Run the
script against it WITHOUT --apply and show me the three lists. Stop if I say no.
3. Create ~/Library/LaunchAgents/com.cred.git-cleanup-merged.plist from File 2.
launchd does not expand ~ or $HOME: replace every HOME_DIR with my real home
directory and REPO_PATH with the absolute repository path. If `which gh` is not in
/opt/homebrew/bin, put its directory first in PATH. Check it with plutil -lint.
4. Load it. If the label is already loaded (a reinstall), unload it first:
launchctl bootout gui/$(id -u)/com.cred.git-cleanup-merged 2>/dev/null || true
then launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.cred.git-cleanup-merged.plist
and confirm with launchctl list | grep git-cleanup.
5. Tell me the time it will run and where the log is.
File 1: ~/.claude/bin/git-cleanup-merged
#!/usr/bin/env bash
# Remove worktrees and delete local branches whose PR is MERGED on GitHub.
#
# Safety rules (a branch/worktree must clear ALL of them):
# - Its newest PR state is MERGED (CLOSED-unmerged and NO-PR are never touched).
# - Local branch head == the PR's merged headRefOid, OR the extra commits'
# content is already identical in origin/develop.
# - Its worktree has no untracked files and no uncommitted tracked changes
# other than a lockfile (a lockfile-only change is removed with the worktree).
# - Its worktree has been idle for --min-idle-hours (default 12).
# - Never the current branch, never develop/main/master/staging.
#
# Usage: git-cleanup-merged [--apply] [-C <repo>]
# default is a DRY RUN.
set -uo pipefail
APPLY=0
REPO="$PWD"
MIN_IDLE_HOURS=12
while [ $# -gt 0 ]; do
case "$1" in
--apply) APPLY=1; shift ;;
-C) [ $# -ge 2 ] || { echo "-C needs a value" >&2; exit 2; }; REPO="$2"; shift 2 ;;
--min-idle-hours) [ $# -ge 2 ] || { echo "--min-idle-hours needs a value" >&2; exit 2; }; MIN_IDLE_HOURS="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
# A non-integer would make the idle comparison below silently false, i.e. treat
# every worktree as idle. Refuse it instead.
case "$MIN_IDLE_HOURS" in ''|*[!0-9]*) echo "--min-idle-hours must be a whole number of hours" >&2; exit 2 ;; esac
# Hours since a worktree last saw git activity (its index file mtime).
# Cheap, but only a proxy: an agent editing files without running git leaves the
# index untouched, so the dirty/untracked checks below are what protect its work.
wt_idle_hours() {
local gd idx
gd=$(git -C "$1" rev-parse --absolute-git-dir 2>/dev/null) || { echo 99999; return; }
idx="$gd/index"
[ -f "$idx" ] || { echo 99999; return; }
python3 - "$idx" <<'PYEOF'
import os,sys,time
try: print(int((time.time()-os.path.getmtime(sys.argv[1]))//3600))
except Exception: print(99999)
PYEOF
}
cd "$REPO" || exit 1
git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repo: $REPO" >&2; exit 1; }
PROTECTED="develop main master staging"
CURRENT=$(git branch --show-current)
echo "== git-cleanup-merged @ $(date '+%Y-%m-%d %H:%M:%S %Z') =="
echo "repo: $(pwd)"
echo "mode: $([ $APPLY -eq 1 ] && echo APPLY || echo 'DRY RUN (use --apply to execute)')"
echo "idle: skip worktrees touched within ${MIN_IDLE_HOURS}h"
echo "current: $CURRENT"
echo
# This repo often has several agent sessions running git concurrently, so a
# fetch can lose a race for FETCH_HEAD.lock. Retry before giving up. A failed
# fetch is safe (gh is the authoritative merge signal, and a stale origin/develop
# only makes the "already in develop" check MORE conservative) but degrades.
fetch_err=""
for attempt in 1 2 3; do
fetch_err=$(git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=120 fetch --prune origin 2>&1) && { fetch_err=""; break; }
[ "$attempt" -lt 3 ] && sleep $((attempt * 5))
done
[ -n "$fetch_err" ] && echo "WARN: fetch failed after 3 attempts, using cached refs: $(echo "$fetch_err" | head -2 | tr '\n' ' ')"
# ---------- fetch ALL PRs in one shot ----------
# (plus one fallback call per branch outside the last 1000 PRs, see below)
# One bulk query instead of ~2 gh calls per branch: on a repo with dozens of
# local branches that is the difference between ~15 minutes and a few seconds.
PRMAP=$(mktemp)
trap 'rm -f "$PRMAP" "${WTMAP:-}"' EXIT
if ! gh pr list --state all --limit 1000 \
--json number,state,headRefName,headRefOid 2>/dev/null \
| python3 -c '
import json,sys
prs = json.load(sys.stdin)
best = {}
for p in prs:
b = p["headRefName"]
cur = best.get(b)
# A branch can carry several PRs. A MERGED one always wins (its content
# landed); otherwise the highest-numbered (newest) PR describes the branch.
key = (1 if p["state"] == "MERGED" else 0, p["number"])
if cur is None or key > cur[0]:
best[b] = (key, p)
for b,(k,p) in best.items():
print("\t".join([b, p["state"], str(p["number"]), p["headRefOid"]]))
' > "$PRMAP"; then
echo "ERROR: could not list PRs from GitHub (gh auth?). Aborting without changes." >&2
exit 1
fi
[ -s "$PRMAP" ] || { echo "ERROR: PR list came back empty. Aborting without changes." >&2; exit 1; }
echo "PRs indexed: $(wc -l < "$PRMAP" | tr -d ' ')"
echo
# ---------- classify every local branch ----------
DEL_BRANCH=()
SKIP=()
while read -r b; do
[ -z "$b" ] && continue
case " $PROTECTED " in *" $b "*) continue ;; esac
row=$(awk -F'\t' -v b="$b" '$1==b{print; exit}' "$PRMAP")
if [ -z "$row" ]; then
# Not in the recent-PR window (the bulk list is capped). Ask about this one
# branch directly, so a branch whose PR merged long ago is still reaped
# instead of silently sticking around forever.
row=$(gh pr list --head "$b" --state all --limit 10 \
--json number,state,headRefName,headRefOid 2>/dev/null \
| python3 -c '
import json,sys
prs=json.load(sys.stdin)
if prs:
p=max(prs,key=lambda p:(1 if p["state"]=="MERGED" else 0,p["number"]))
print("\t".join([p["headRefName"],p["state"],str(p["number"]),p["headRefOid"]]))
')
fi
if [ -z "$row" ]; then
SKIP+=("$b|NO-PR|no pull request found"); continue
fi
state=$(printf '%s' "$row" | cut -f2)
num=$(printf '%s' "$row" | cut -f3)
oid=$(printf '%s' "$row" | cut -f4)
if [ "$state" != "MERGED" ]; then
SKIP+=("$b|#$num $state|not merged"); continue
fi
loc=$(git rev-parse "$b")
if [ "$loc" != "$oid" ]; then
# Commits past the merged head are safe only if every file they touch is
# byte-identical in origin/develop (i.e. it landed via a follow-up PR).
if ! git cat-file -e "${oid}^{commit}" 2>/dev/null; then
SKIP+=("$b|#$num MERGED|merged head ${oid:0:9} not available locally"); continue
fi
extra_ok=1
while read -r f; do
[ -z "$f" ] && continue
git diff --quiet origin/develop "$b" -- "$f" 2>/dev/null || { extra_ok=0; break; }
done < <(git diff --name-only "$oid" "$b" 2>/dev/null)
if [ "$extra_ok" -ne 1 ]; then
SKIP+=("$b|#$num MERGED|has commits past the merged head not in develop"); continue
fi
fi
if [ "$b" = "$CURRENT" ]; then
SKIP+=("$b|#$num MERGED|is the current branch of the main checkout"); continue
fi
DEL_BRANCH+=("$b|$num")
done < <(git for-each-ref --format='%(refname:short)' refs/heads/)
# ---------- map branch -> worktree, check dirtiness ----------
# bash 3.2 on macOS has no associative arrays: use a plain lookup table file.
WTMAP=$(mktemp)
git worktree list --porcelain \
| awk '/^worktree /{sub(/^worktree /,""); wt=$0} /^branch /{sub(/^branch refs\/heads\//,""); print $0"\t"wt}' > "$WTMAP"
MAIN_WT=$(git worktree list --porcelain | head -1 | sed 's/^worktree //')
DEL_WT=()
DEL_BRANCH_FINAL=()
for entry in ${DEL_BRANCH[@]+"${DEL_BRANCH[@]}"}; do
b="${entry%%|*}"; num="${entry##*|}"
wt=$(awk -F'\t' -v b="$b" '$1==b{print $2; exit}' "$WTMAP")
if [ -n "$wt" ] && [ "$wt" != "$MAIN_WT" ]; then
idle=$(wt_idle_hours "$wt")
if [ "$idle" -lt "$MIN_IDLE_HOURS" ] 2>/dev/null; then
SKIP+=("$b|#$num MERGED|worktree active ${idle}h ago (< ${MIN_IDLE_HOURS}h idle guard)")
continue
fi
porcelain=$(git -C "$wt" --no-optional-locks status --porcelain 2>/dev/null)
# Untracked files always block: removing the worktree would delete them, and
# nothing about a merged PR says they were meant to be thrown away.
untracked=$(printf '%s\n' "$porcelain" | grep '^??' | cut -c4- | tr '\n' ' ')
if [ -n "${untracked// /}" ]; then
SKIP+=("$b|#$num MERGED|worktree has untracked files: $untracked")
continue
fi
tracked=$(printf '%s\n' "$porcelain" | grep -c . | tr -d ' ')
if [ "$tracked" -gt 0 ]; then
files=$(printf '%s\n' "$porcelain" | cut -c4- | tr '\n' ' ')
real=$(printf '%s\n' "$porcelain" | cut -c4- | grep -vE '(^|/)(bun\.lock|package-lock\.json|yarn\.lock)$' | grep -c . | tr -d ' ')
if [ "$real" -gt 0 ]; then
SKIP+=("$b|#$num MERGED|worktree has uncommitted work: $files")
continue
fi
DEL_WT+=("$wt|$b|dirty-lockfile-only: $files")
else
DEL_WT+=("$wt|$b|clean")
fi
fi
DEL_BRANCH_FINAL+=("$entry")
done
# ---------- report ----------
echo "--- WORKTREES to remove (${#DEL_WT[@]}) ---"
for e in ${DEL_WT[@]+"${DEL_WT[@]}"}; do [ -n "$e" ] && printf ' %s\n branch=%s [%s]\n' "${e%%|*}" "$(echo "$e"|cut -d'|' -f2)" "$(echo "$e"|cut -d'|' -f3)"; done
echo
echo "--- BRANCHES to delete (${#DEL_BRANCH_FINAL[@]}) ---"
for e in ${DEL_BRANCH_FINAL[@]+"${DEL_BRANCH_FINAL[@]}"}; do [ -n "$e" ] && printf ' %-60s (PR #%s)\n' "${e%%|*}" "${e##*|}"; done
echo
echo "--- KEPT (${#SKIP[@]}) ---"
for e in ${SKIP[@]+"${SKIP[@]}"}; do [ -n "$e" ] && printf ' %-58s %-16s %s\n' "$(echo "$e"|cut -d'|' -f1)" "$(echo "$e"|cut -d'|' -f2)" "$(echo "$e"|cut -d'|' -f3)"; done
echo
if [ $APPLY -eq 0 ]; then
echo "DRY RUN — nothing changed. Re-run with --apply to execute."
exit 0
fi
echo "--- applying ---"
for e in ${DEL_WT[@]+"${DEL_WT[@]}"}; do
[ -z "$e" ] && continue
wt="${e%%|*}"; note="$(echo "$e"|cut -d'|' -f3)"
if [ "$note" = "clean" ]; then
git worktree remove "$wt" && echo " removed worktree $wt"
elif git -C "$wt" --no-optional-locks status --porcelain 2>/dev/null | cut -c4- \
| grep -qvE '(^|/)(bun\.lock|package-lock\.json|yarn\.lock)$'; then
# --force deletes whatever is there NOW, so re-check it is still only a lockfile.
echo " KEPT worktree $wt: changed since it was classified"
else
git worktree remove --force "$wt" && echo " removed worktree $wt ($note)"
fi
done
git worktree prune
for e in ${DEL_BRANCH_FINAL[@]+"${DEL_BRANCH_FINAL[@]}"}; do
[ -z "$e" ] && continue
b="${e%%|*}"
git branch -D "$b" >/dev/null && echo " deleted branch $b"
done
echo
echo "done. worktrees=$(git worktree list | wc -l | tr -d ' ') branches=$(git branch | wc -l | tr -d ' ')"
File 2: ~/Library/LaunchAgents/com.cred.git-cleanup-merged.plist
Replace HOME_DIR and REPO_PATH with absolute paths before loading it.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cred.git-cleanup-merged</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>HOME_DIR/.claude/bin/git-cleanup-merged -C REPO_PATH --apply --min-idle-hours 12 >> HOME_DIR/.claude/logs/git-cleanup-merged.log 2>&1</string>
</array>
<!-- launchd starts with a bare PATH; gh and git live in homebrew -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<!-- daily at 09:15 local; a run missed while the Mac slept happens on wake -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>15</integer>
</dict>
<!-- do not run when the job is loaded, only on schedule -->
<key>RunAtLoad</key>
<false/>
<!-- Standard, NOT Background: launchd throttles Background jobs so hard that
a `git fetch` on this monorepo takes minutes instead of seconds. -->
<key>ProcessType</key>
<string>Standard</string>
<key>StandardOutPath</key>
<string>HOME_DIR/.claude/logs/git-cleanup-merged.launchd.log</string>
<key>StandardErrorPath</key>
<string>HOME_DIR/.claude/logs/git-cleanup-merged.launchd.log</string>
</dict>
</plist>