Skip to content

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 develop misses 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:

  1. The branch's PR is MERGED. A branch with no PR, or only a closed-unmerged PR, is never touched.
  2. 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).
  3. 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.
  4. 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.
  5. It is not the current branch and not develop, main, master or staging.

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 develop is the default branch for rule 2. In a repository whose default is main, 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 gh logged 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 status does 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 &gt;&gt; HOME_DIR/.claude/logs/git-cleanup-merged.log 2&gt;&amp;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>