Cross-platform concepts. For OS-specific install + scheduling, see mac.md or windows.md — each is self-contained.
Every machine keeps its repos in one folder, but the name differs per box
(~/www on the old Mac, ~/Developer on newer ones, C:\dev on Windows). To
avoid hardcoding that path, everything below derives from a single env var:
export DEV_ROOT="$HOME/Developer" # or ~/www — wherever your repos liveSet it once and every script here (gtr-clean, the LaunchAgent/cron job,
hgui/htui, per-repo AWS profiles) follows automatically. In this setup it's
exported by a chezmoi-managed ~/.zsh.d/05-devroot.zsh,
and passed explicitly into the LaunchAgent/cron env (they don't source your
shell). Scripts fall back to ~/www if it's unset.
One private repo provisions the whole machine. chezmoi keeps the shell
config, starship, the cleanup script + scheduler, the repo manifest (name +
origin + branch), and the real .env files in a single private repo (it
holds secrets, so keep it private). On a fresh machine:
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply <you>/dotfilesinstalls the CLI deps (brew, gh, glab, git-gtr, …), prompts for DEV_ROOT
(default ~/Developer), clones every repo into it, drops the .env files in
place, and schedules the hourly worktree prune — per OS. Worktrees are never
synced; they're regenerated on demand with gtr.
gh resolves the host repo from remotes, not from "I'm in this directory."
If a clone has more than one GitHub remote (origin, upstream, fork, …)
and nothing is pinned, commands like gh pr checkout 123 die with:
No default remote repository has been set
Even though you're standing in the right repo. One-shot fix:
gh repo set-default origin # or: owner/repoFor every repo automatically, drop this in ~/.zsh.d/37-gh.zsh (chezmoi-
managed here as dot_zsh.d/37-gh.zsh). It wraps gh so the first call in a
repo with no gh-resolved pin silently prefers origin → upstream → first
github.com remote. Full file: 37-gh.zsh.
Manual override anytime: gh repo set-default <remote|owner/repo> / --view / --unset.
Git, GitHub, and GitLab do not remove local worktree folders when a PR/MR is merged or closed. git-gtr fixes that — it reads PR/MR state via gh (GitHub) or glab (GitLab), auto-detected per repo from origin, and removes the matching worktrees + branches.
- Removes: worktrees whose branch has a merged or closed PR/MR.
- Keeps: open PRs, dirty worktrees (unless
--force), branches with no PR/MR. - Self-hosted GitLab:
git gtr config set gtr.provider gitlab
cd /path/to/repo
git fetch --prune origin
git gtr clean --merged --closed --dry-run # preview
git gtr clean --merged --closed --yes # do itfor d in "${DEV_ROOT:-$HOME/www}"/*/; do
git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 || continue
# main checkouts only (skip linked worktrees)
[ "$(git -C "$d" rev-parse --git-dir)" = "$(git -C "$d" rev-parse --git-common-dir)" ] || continue
echo "== $d"
# git-gtr won't prune unless the <repo>-worktrees container exists; make it,
# clean, then remove it again if still empty so it doesn't litter $DEV_ROOT.
mkdir -p "${d%/}-worktrees"
git -C "$d" fetch --prune origin 2>/dev/null || true
git -C "$d" gtr clean --merged --closed --yes
rmdir "${d%/}-worktrees" 2>/dev/null || true
done~/bin/gtr-clean-worktrees.sh — used by the mac LaunchAgent and the Windows Task Scheduler job. Identical logic on both; only PATH differs.
#!/usr/bin/env bash
# Prune local git worktrees whose GitHub PRs/MRs are merged or closed,
# and reap orphan husk directories git leaves behind.
# Uses git-gtr (https://github.com/coderabbitai/git-worktree-runner).
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:/usr/bin:/bin:$PATH"
# Dev root: passed by the LaunchAgent/cron env, exported by the shell, or fallback.
DEV_ROOT="${DEV_ROOT:-$HOME/www}"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
_is_main_worktree() {
local repo="${1%/}"
[[ -d "$repo" ]] || return 1
git -C "$repo" rev-parse --is-inside-work-tree &>/dev/null || return 1
[[ "$(git -C "$repo" rev-parse --git-dir)" == "$(git -C "$repo" rev-parse --git-common-dir)" ]]
}
# Reap orphan husk dirs: directories under $DEV_ROOT that look like worktree
# siblings of <repo> (match <repo>-*) but are NOT registered in that repo's
# `git worktree list`. git-gtr unregisters merged worktrees but leaves the
# directory behind when it contains untracked/gitignored content (e.g. a Vite
# build cache). Those husks accumulate forever without this sweep.
#
# SAFETY — refuse to touch anything that still looks like a real checkout:
# - must not match any registered worktree path for this repo
# - must NOT be a live git work tree (independent repo like
# brooklyn-skills-personal next to brooklyn-skills would otherwise match
# the prefix glob and get deleted)
# - must not be the main repo or $DEV_ROOT itself
# - require a trailing hyphen in the glob (already: "${name}-"*) so
# hermes-agent never matches hermes-agentfoo without the separator
_reap_orphans() {
local repo="${1%/}"
local name; name="$(basename "$repo")"
local dir; dir="$(dirname "$repo")"
# Collect registered worktree paths for this repo.
local -a registered=()
local wt
while IFS= read -r wt; do
[[ -n "$wt" ]] && registered+=("$wt")
done < <(git -C "$repo" worktree list --porcelain 2>/dev/null \
| awk '/^worktree /{print $2}')
local sibling
for sibling in "$dir/${name}-"*; do
[[ -d "$sibling" ]] || continue
# Never the main repo or DEV_ROOT itself (path equality).
[[ "$sibling" == "$repo" || "$sibling" == "$DEV_ROOT" ]] && continue
# Skip if still a registered worktree of THIS repo.
local found=0
for wt in "${registered[@]}"; do
[[ "$wt" == "$sibling" ]] && { found=1; break; }
done
((found)) && continue
# CRITICAL: if this dir is still a live git checkout (its own repo OR a
# worktree of some other repo), leave it alone. Only true husks — dirs
# with no .git, left behind after git-gtr unregistered them — get removed.
if git -C "$sibling" rev-parse --is-inside-work-tree &>/dev/null; then
log "skip live checkout (not a husk): $sibling"
continue
fi
log "reaping orphan husk: $sibling"
rm -rf -- "$sibling"
done
}
# Reap empty sibling <repo>-worktrees containers that older git-gtr releases
# create. Cosmetic, but keeps $DEV_ROOT tidy.
_reap_empty_worktree_containers() {
local repo="${1%/}"
local c; c="$(dirname "$repo")/$(basename "$repo")-worktrees"
[[ -d "$c" ]] || return 0
# Only remove if truly empty (rmdir fails otherwise — safe).
rmdir "$c" 2>/dev/null || true
}
_clean_repo() {
local repo="${1%/}"
log "repo: $repo"
# git-gtr early-returns before --merged/--closed if its default
# <repo>-worktrees dir is missing (even when sibling worktrees exist).
mkdir -p "$(dirname "$repo")/$(basename "$repo")-worktrees"
git -C "$repo" fetch --prune origin 2>/dev/null \
|| git -C "$repo" fetch --prune 2>/dev/null \
|| log "warn: fetch failed for $repo"
local rc=0
git -C "$repo" gtr clean --merged --closed --yes || rc=$?
# Drop the container if it's now empty so $DEV_ROOT stays tidy.
_reap_empty_worktree_containers "$repo"
# Reap orphan husk dirs git left behind (unregistered but not deleted).
_reap_orphans "$repo"
return "$rc"
}
main() {
local repos=()
if [[ "${GTR_CLEAN_ALL:-0}" == "1" ]]; then
local d
for d in "$DEV_ROOT"/*/; do
_is_main_worktree "$d" || continue
repos+=("${d%/}")
done
else
repos=("${GTR_CLEAN_REPO:-$DEV_ROOT/hermes-agent}")
fi
if ((${#repos[@]} == 0)); then
log "no repos to clean"
exit 0
fi
local repo
for repo in "${repos[@]}"; do
_clean_repo "$repo" || log "warn: clean failed for $repo"
done
}
main "$@"| Env var | Default | Meaning |
|---|---|---|
GTR_CLEAN_ALL |
0 |
1 → every main checkout under $DEV_ROOT/* (all repos, GitHub + GitLab) |
GTR_CLEAN_REPO |
$DEV_ROOT/hermes-agent |
Single repo when GTR_CLEAN_ALL=0 |
DEV_ROOT |
~/www |
Root folder holding all your repos (see top section) |
A git worktree only carries tracked files. The expensive stuff —
node_modules, a Python .venv, build caches — is gitignored, so a
freshly-created worktree has none of it and can't run until you reinstall.
Doing that per worktree wastes disk and minutes.
Split every un-tracked resource into two buckets:
| Resource | Content-identical across branches? | Strategy |
|---|---|---|
node_modules when the lockfile matches the source checkout |
yes | symlink from one canonical checkout |
node_modules when the lockfile diverges |
no | local install, stamped so it only reinstalls when the lock changes |
Python .venv |
effectively yes | run the source checkout's interpreter directly |
| Docker stacks, dev-server ports, DB volumes | no — runtime state | isolate per worktree (next section) |
Share what's byte-identical and expensive to build; isolate what holds
live state. Deleting a symlinked/idle node_modules whose lock still matches
source is safe — re-link it next run.
The minimal symlink helper, generic to any lockfile-based project:
# Link node_modules from a canonical checkout when lockfiles match byte-for-byte;
# otherwise the branch bumped a dep and needs its own install.
wt_link_deps() {
local worktree="${1:-$PWD}" source="$2"
if cmp -s "$worktree/package-lock.json" "$source/package-lock.json"; then
[[ -e "$worktree/node_modules" ]] || ln -s "$source/node_modules" "$worktree/node_modules"
else
( cd "$worktree" && npm ci )
fi
}A fully worked example — lock-stamping, partial-install repair, --dev
from-source builds, Electron backend cleanup on exit — lives in its own gist:
Hermes worktree launchers (hermes / htui / hgui),
complete with helpers.
Deps you share (symlink one copy). Running state you isolate — two
worktrees that both docker compose up or bind the same port fight over the same
containers, volumes, networks, and sockets. Same worktree magic, inverted:
instead of pointing many worktrees at one resource, give each its own namespace.
| Resource | Collision if shared | Fix (per worktree) |
|---|---|---|
| Compose containers / volumes / networks | compose up in branch B stops/reuses branch A's stack |
unique COMPOSE_PROJECT_NAME derived from the worktree dir |
| DB / cache volumes | branches clobber each other's data | falls out of COMPOSE_PROJECT_NAME (volumes are prefixed by project) |
| Dev-server / HMR ports | second server can't bind (some apps pin a fixed port → one at a time) | offset the port by a per-worktree number, or accept one-at-a-time |
Host bind-mounts (.:/app) |
none — each worktree is already a distinct path | nothing; the worktree path is the isolation |
Gitignored config (.env, certs, seed data) |
drift between copies | symlink from the source checkout (this one you do share) |
Drop this in a worktree's .envrc (direnv) or source it
before running compose. It namespaces the stack and shares secrets from the
source checkout instead of copying them:
# Per-worktree Docker isolation. Namespace the compose stack by worktree dir so
# `docker compose up` never touches another branch's containers/volumes/networks.
export COMPOSE_PROJECT_NAME="$(basename "$PWD" | tr -C 'a-z0-9' '-' | tr 'A-Z' 'a-z')"
# Share gitignored config from the source checkout instead of copying it into
# every worktree — same symlink trick used for deps. Point SOURCE at that checkout.
SOURCE="${DEV_ROOT:-$HOME/dev}/<repo>"
for f in .env .env.local; do
[[ -e "$f" || ! -e "$SOURCE/$f" ]] || ln -s "$SOURCE/$f" "$f"
done
# Stable per-worktree port offset (0–99) so parallel dev servers/HMR don't clash.
# Feed $DEV_PORT into your compose file / dev script.
_wt_offset="$(( $(cksum <<<"$PWD" | cut -d' ' -f1) % 100 ))"
export DEV_PORT="$(( 3000 + _wt_offset ))"Then reference the vars in docker-compose.yml
(container_name: ${COMPOSE_PROJECT_NAME}-web, ports: ["${DEV_PORT}:3000"])
or your dev script. git gtr prunes the worktree;
docker compose -p "$COMPOSE_PROJECT_NAME" down -v tears down its stack. To
share one long-lived dev DB across all worktrees instead of per-branch data,
point them at a single external volume rather than a project-scoped one.
Cursor stores all chat/agent history in one SQLite file. It grows unbounded and there's no auto-cleanup. Do not delete the live file — it breaks chat loading ("Loading Chat…" forever).
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb |
| Windows | %APPDATA%\Cursor\User\globalStorage\state.vscdb |
Fix via Command Palette (both platforms):
Developer: Delete Old Chats…— choose a retention window; deletes older chats and compacts the file.Developer: GC Agent KV Blobs— clears orphaned blobs and runs VACUUM (slow on a multi-GB DB).
VACUUM alone won't shrink it if the data is live — the space is real saved history, so you must prune old chats to reclaim it.