Find and reap merged git worktrees.
stalewood scans a directory tree for git worktrees and tells you which ones
are safe to delete — those whose work is already integrated into another
branch — and can reap them for you. A --lint mode drops into a git hook to
keep stale worktrees from piling up.
It finds worktrees three ways: directories under .claude/worktrees/,
git worktree list of every repo it encounters, and abandoned worktrees
(orphan directories and stale entries) — so nothing slips through.
# Go toolchain
go install github.com/retif/stalewood@latest
# Homebrew
brew install retif/tap/stalewood
# npm (or run without installing: npx stalewood)
npm install -g stalewood
# Nix
nix run github:retif/stalewood -- --helpPrebuilt binaries for Linux, macOS and Windows — plus Linux .deb,
.rpm and .snap packages — are on the
releases page.
git clone https://github.com/retif/stalewood
cd stalewood && go build -o stalewood .The binary is self-contained and dependency-free. With Nix, nix develop
gives a dev shell and nix build builds the tool; common tasks are in the
justfile (just build, just test, just check).
stalewood [flags] [path]path defaults to the current directory.
| Flag | Effect |
|---|---|
--size |
measure each worktree's disk usage |
--base REF |
test every worktree against REF instead of its own base |
--lint SEL |
lint mode: exit 1 if a worktree matches SEL (repeatable) |
--json |
emit JSON instead of the tree |
--prune |
remove worktrees whose work is merged |
--force |
with --prune, also remove merged worktrees that are dirty/locked |
--dry-run |
with --prune, show what would be removed without removing it |
--verbose |
log per-worktree detail to stderr |
--quiet |
suppress progress output |
--print |
print the whole report at once (disable the pager) |
--no-pager |
alias for --print |
--version |
print version and exit |
--json-schema |
print the JSON Schema for --json output |
-h, --help |
show help |
Exit codes: 0 success, 1 runtime failure, 2 usage error.
stalewood --size ~/projects # report, with disk usage
stalewood --base origin/main ~/repo # force a specific base
stalewood --prune --dry-run ~/projects # preview what --prune would remove
stalewood --prune ~/projects # remove merged worktrees
stalewood --json ~/projects # machine-readable output (grouped by repo)The report is a tree grouped by repo. Each ● node is a repo (with its full
path); each ├─/└─ node is a worktree showing a glyph, name, verdict and
tags; the ├── leaves give the worktree's full path, branch and base.
● gitea /home/you/projects/gitea
├─ ✗ gitea-toasts unmerged [untracked files]
│ ├── path /home/you/projects/gitea-toasts
│ ├── branch sse-toasts
│ └── base origin/main
└─ ✓ gitea-issue-fixes merged -> origin/main [claude]
├── path /home/you/projects/gitea/.claude/worktrees/gitea-issue-fixes
├── branch fix/issue-19-sse-state
└── base fix/user-project-move-multiproject-detach (sha)
A summary and a legend follow; the legend describes only the glyphs and tags that actually appear in that run.
| Marker | Meaning |
|---|---|
✓ / ✗ |
merged / unmerged |
⚠ |
abandoned (orphan dir or stale git entry) |
! |
error — the worktree could not be analyzed |
-> REF |
merged, but into REF — a branch other than its own base |
[claude] |
created by Claude Code (under .claude/worktrees/) |
[modified files] |
tracked files have uncommitted changes |
[untracked files] |
the worktree has untracked files |
[locked] |
a git worktree lock is held |
[lock-stale] |
locked, but the process that took the lock is gone |
[git-prunable] |
git's own worktree list flags the entry prunable |
Worktrees are found from three sources, unioned and de-duplicated by path:
-
.claude/worktrees/*— Claude Code worktree directories, found by walking the tree.node_modulesand.gitare skipped; a child with no.gitentry (e.g. a committed test fixture) is not a worktree and is ignored. -
git worktree list— every git repo found under the path is asked for its linked worktrees, so worktrees living outside.claude/worktrees/(e.g. ones you made by hand) are included too. The main checkout is not. -
Abandoned worktrees — found by cross-referencing the two:
- orphan dir (
abandoned-orphan) — a worktree directory on disk that no repo'sgit worktree listknows about (its.gitfile points to a deleted git dir); - stale entry (
abandoned-stale) — agit worktree listentry whose directory is gone.
Abandoned worktrees carry no merge analysis; they show a
fixleaf with the suggested cleanup. - orphan dir (
A live worktree counts as merged if either:
- its HEAD is an ancestor of its base branch
(
git merge-base --is-ancestor); or - its HEAD is contained in any branch other than its own
(
git for-each-ref --contains) — catches work integrated into a branch other than the base.
By default each worktree is tested against the branch it was forked from. The
base is recovered in this order; the base leaf suffix shows which step won:
| Source | Suffix | How |
|---|---|---|
--base REF |
(flag) |
explicit override, applied to every worktree |
| reflog ref | (none) | the branch's Created from <ref> reflog entry |
| reflog SHA | (sha) |
that reflog entry's commit, named via name-rev |
| upstream | (upstream) |
the branch's configured upstream branch |
| auto | (auto) |
the repo's main branch (remote HEAD preferred) |
The reflog-SHA step recovers a base even when the reflog ref is the unhelpful
literal HEAD or names a since-removed remote.
- Squash / rebase merges. Both checks are reachability-based; a branch that
was squash-merged or rebased onto its target shows as
unmerged. Verify by hand before force-pruning. - Sibling worktrees. If two worktrees share commits, each may report the
other's branch as containing its work. Check
merged -> REFbefore pruning.
--prune runs git worktree remove on every merged worktree — anywhere,
not just under .claude/worktrees/. Running with --prune --dry-run (or
with no flags at all) reports exactly what --prune would remove without
touching anything. Unmerged worktrees are kept; a merged worktree that is
dirty or locked is skipped unless --force is given — a [lock-stale] skip
says so, since forcing it is safe. Abandoned worktrees are never removed by
--prune. Exit status is non-zero if any removal failed.
--lint turns stalewood into a checker for a single repo — built for git
hooks. It scans only the git repo containing [path] (no directory walk, so it
is fast enough for pre-push) and exits 1 if any worktree matches.
Each --lint value is a comma-separated AND-group of predicates; repeat
--lint to OR the groups; prefix a predicate with ! to negate it.
stalewood --lint abandoned # fail if any abandoned worktree
stalewood --lint abandoned --lint lock-stale # abandoned OR lock-stale
stalewood --lint removable,manual # merged AND not a Claude worktree
stalewood --lint merged,untracked # merged AND has untracked filesPredicates: merged unmerged live abandoned orphan stale dirty
modified untracked locked lock-stale claude manual detached
error git-prunable removable any.
Matching worktrees are printed; exit status is 1 on a match, 0 when clean
(and silent), 2 on a bad selector. Use it in a global pre-push hook
(git config --global core.hooksPath <dir>):
#!/bin/sh
# <hooks>/pre-push - block pushes while stale worktrees linger
exec stalewood --lint abandoned --lint lock-stalestalewood adapts to where its output goes:
- Colour & weight — glyphs and verdicts are bold and colour-coded by
severity, repo nodes bold-cyan, connectors dim. On an interactive terminal
only; disabled when piped or when
NO_COLORis set. - Progress — a transient progress line is shown on an interactive stderr
during a scan.
--quietsilences it;--verbosereplaces it with durable per-worktree log lines on stderr. - Paging — human output is paged through
$PAGER(defaultless -FIRX) on an interactive terminal;--no-pagerdisables it. JSON is never paged.
Piped or redirected, output is plain, unpaged and uncoloured. Every git subprocess runs under a timeout so a wedged repo cannot stall the scan.
Every release archive carries a signed SLSA build-provenance attestation — proof it was built from this repository's CI. Verify a downloaded archive with the GitHub CLI:
gh attestation verify stalewood_<version>_<os>_<arch>.tar.gz \
--repo retif/stalewoodchecksums.txt is signed with keyless cosign:
cosign verify-blob checksums.txt \
--bundle checksums.txt.bundle \
--certificate-identity-regexp '^https://github.com/retif/stalewood' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comAn SBOM (*.sbom.json, from syft) and a SLSA provenance file
(*.intoto.jsonl, verifiable with slsa-verifier) are attached to every
release.
See CONTRIBUTING.md for the pull-request workflow and CLAUDE.md for the design and CLI conventions. CLI behaviour follows clig.dev where reasonable.