Skip to content

retif/stalewood

Repository files navigation

stalewood

Find and reap merged git worktrees.

Go Reference CI CodeQL govulncheck OpenSSF Scorecard License: MIT

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.

Install

# 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 -- --help

Prebuilt binaries for Linux, macOS and Windows — plus Linux .deb, .rpm and .snap packages — are on the releases page.

Build from source

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).

Usage

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.

Examples

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

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

Discovery

Worktrees are found from three sources, unioned and de-duplicated by path:

  1. .claude/worktrees/* — Claude Code worktree directories, found by walking the tree. node_modules and .git are skipped; a child with no .git entry (e.g. a committed test fixture) is not a worktree and is ignored.

  2. 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.

  3. Abandoned worktrees — found by cross-referencing the two:

    • orphan dir (abandoned-orphan) — a worktree directory on disk that no repo's git worktree list knows about (its .git file points to a deleted git dir);
    • stale entry (abandoned-stale) — a git worktree list entry whose directory is gone.

    Abandoned worktrees carry no merge analysis; they show a fix leaf with the suggested cleanup.

Merge classification

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.

Base detection

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.

Caveats

  • 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 -> REF before pruning.

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 mode

--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 files

Predicates: 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-stale

Terminal behaviour

stalewood 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_COLOR is set.
  • Progress — a transient progress line is shown on an interactive stderr during a scan. --quiet silences it; --verbose replaces it with durable per-worktree log lines on stderr.
  • Paging — human output is paged through $PAGER (default less -FIRX) on an interactive terminal; --no-pager disables 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.

Verifying a release

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/stalewood

checksums.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.com

An SBOM (*.sbom.json, from syft) and a SLSA provenance file (*.intoto.jsonl, verifiable with slsa-verifier) are attached to every release.

Contributing

See CONTRIBUTING.md for the pull-request workflow and CLAUDE.md for the design and CLI conventions. CLI behaviour follows clig.dev where reasonable.

About

Find and reap merged git worktrees — detects, reports and prunes stale git worktrees

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors