https://github.com/retif/stalewood
Find and reap merged git worktrees — detects, reports and prunes stale git worktrees
https://github.com/retif/stalewood
cli developer-tools git git-worktree golang worktree
Last synced: 1 day ago
JSON representation
Find and reap merged git worktrees — detects, reports and prunes stale git worktrees
- Host: GitHub
- URL: https://github.com/retif/stalewood
- Owner: retif
- License: mit
- Created: 2026-05-22T09:50:58.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-22T13:51:25.000Z (about 1 month ago)
- Last Synced: 2026-05-22T16:49:16.557Z (about 1 month ago)
- Topics: cli, developer-tools, git, git-worktree, golang, worktree
- Language: Go
- Size: 154 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Security: SECURITY.md
Awesome Lists containing this project
README
# stalewood
> Find and reap merged git worktrees.
[](https://pkg.go.dev/github.com/retif/stalewood)
[](https://github.com/retif/stalewood/actions/workflows/ci.yml)
[](https://github.com/retif/stalewood/actions/workflows/codeql.yml)
[](https://github.com/retif/stalewood/actions/workflows/govulncheck.yml)
[](https://scorecard.dev/viewer/?uri=github.com/retif/stalewood)
[](LICENSE)
`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
```sh
# 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](https://github.com/retif/stalewood/releases).
## Build from source
```sh
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
```sh
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
```sh
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.
```text
● 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 ` 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.
```sh
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 `):
```sh
#!/bin/sh
# /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:
```sh
gh attestation verify stalewood___.tar.gz \
--repo retif/stalewood
```
`checksums.txt` is signed with keyless cosign:
```sh
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](CONTRIBUTING.md) for the pull-request workflow and
[CLAUDE.md](CLAUDE.md) for the design and CLI conventions. CLI behaviour
follows [clig.dev](https://clig.dev) where reasonable.