feat(cleanup): prune orphaned git worktrees on gsd-cleanup#620
Conversation
GSD automatically creates worktrees during phase execution but has no corresponding cleanup path. Worktrees accumulate indefinitely — one project logged 41 worktrees with 32 merged branches that were never removed. Extends gsd-cleanup with a new `prune_orphaned_worktrees` step that: - Enumerates non-primary worktrees whose branch is merged into main - Shows a dry-run list before acting (same confirm gate as branch pruning) - Runs `git worktree remove --force` + `git branch -d` for each orphan - Runs `git worktree prune` to clean dangling metadata stubs Also extends `show_dry_run` to surface these candidates and updates the confirmation prompt, report output, and success criteria accordingly. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
PR template requiredThis PR does not follow one of the required pull request templates from the contribution guidelines. Please update the PR description to use the correct typed template: This check only validates template format. It does not close PRs for an unfilled issue number such as Detected problem: PR body does not match the fix, enhancement, or feature template. |
git branch marks worktree-checked-out branches with + not space. The sed pattern s/^[* ]*// missed this, so grep -qx never matched and no orphaned worktrees were detected. Strip + alongside * and space. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
|
Thanks for tackling this. Retargeting to Requesting changes on the cleanup implementation before merge:
Also, the PR template check is still failing, so the PR body needs to be updated to one of the required templates. Once those are addressed, I’d be happy to re-review. The feature is useful; the main thing is making sure cleanup never becomes a data-loss path. |
|
please read and use the repo guidelines for formatting of the PR, these are important for how change logs and documentation are updated. |
trek-e
left a comment
There was a problem hiding this comment.
Combined review — /code-review + /adversarial-review (Codex) + /security-review + Artificer laws
Thank you for this, Roland — orphaned-worktree pruning is a genuinely useful addition to gsd-cleanup, and I want to call out the parts you clearly thought hard about: the dry-run preview, the explicit "re-enumerate at execution so it matches what the user confirmed" intent, and the deliberate choice of -d (safe-delete) over -D for branches. That instinct toward safety is exactly right for destructive automation.
That said, I'm requesting changes — this touches destructive operations (removing worktrees, deleting branches), and both my review and an independent Codex pass surfaced several ways it can silently destroy uncommitted work or target the wrong thing. I want to make sure you get full value from a rebase, so everything's below.
🚧 Structural blockers (independent of the logic)
- Branch is 40 commits behind
nextand predates theget-shit-done→gsd-corerename. This PR editsget-shit-done/workflows/cleanup.md, but onnextthat file now lives atgsd-core/workflows/cleanup.md(the old path is gone, per the ADR-0008 migration). As-is, these edits won't land on the canonical file. Please rebase onto currentnextand re-apply the change togsd-core/workflows/cleanup.md— which has also evolved, so expect to reconcile. - No changeset. This is a user-facing
feat; please add a.changeset/*.mdfragment (the Changeset-Required gate will want it). - (Carryover from my earlier note) please align the PR description with the repo's PR template / contribution guidelines.
🔴 Critical — data-loss & self-destruct (both reviewers, confirmed against the lines)
git worktree remove "$wt_path" --force 2>/dev/nulldiscards uncommitted work (:217,:231). The comment justifies--forceas safe because "the branch is merged" — but merged says nothing about uncommitted/unstaged/untracked files in that worktree (WIP edits, a local.env, an un-applied patch).--forcebypasses git's dirty-worktree protection and the2>/dev/nullhides the only signal you'd lose work. Don't--force: try the non-forcegit worktree removeand, on failure, skip and report the worktree as "dirty — left in place" rather than forcing.- No guard against removing the currently-active worktree (
:214, onlytail -n +2skips the primary).gsd-cleanupfrequently runs from inside a linked worktree (e.g. aclaude/...session worktree). If that worktree's branch is considered merged, the loop will try togit worktree removethe directory it's running in. Add an explicit guard: resolvegit rev-parse --show-topleveland skip any worktree whose path equals it (and the primary).
🔴 High — wrong targets
- Hardcoded
git branch --merged main(:120,:207). This repo's default branch isnext, notmain. A branch merged intonext(the real integration branch) won't appear; worse, if localmainis stale or only-via-main, you can flag branches that aren't actually integrated intonextand remove their worktrees. Resolve the default dynamically:git symbolic-ref --short refs/remotes/origin/HEAD(fallback tonext/main), and compare against the remote-tracking ref after a fetch. - Fragile
git worktree list | awk '{print $1, $3}' | tr -d '[]'(:114,:214). Breaks on worktree paths containing spaces (fields shift →$1truncated,$3is the SHA, and you then pass a wrong path toremove) and on detached-HEAD worktrees (no branch field). Usegit worktree list --porcelain(ideally-z) and parse theworktree/branchrecords — this also removes thetr -d '[]'hack. - Dry-run/execution race (
:203claims "matches exactly what the user confirmed", but:207/:214recompute). Between the preview and execution, other steps (prune_local_branchesdoesgit fetch --prune) can change the merged set, so a worktree never shown to the user can be removed. Capture the confirmed list once in the dry-run and operate only on that captured set (intersected with still-present worktrees), rather than recomputing.
🟡 Medium
grep -qx "$branch"treats the branch name as a regex (:215). Valid branch names with.(e.g.feat/foo.bar) can match the wrong branch (feat/fooXbar). Usegrep -Fqx -- "$branch". (Note:feat/[x]isn't a valid git ref, but.absolutely is, so this is real.)git worktree remove "$wt_path" --force— arg order works on modern git (verified locally on 2.50.1) but the canonical safe form isgit worktree remove --force "$wt_path"; prefer it for older-git portability.
🟢 Low
- The report count conflates
git worktree prune(metadata stubs for already-gone dirs) withgit worktree remove(destructive). Consider reporting them separately so "Worktrees removed: N" reflects only the destructive count.
Security review
No classic vulnerabilities (no untrusted network/subprocess input) — branch names aren't a meaningful injection vector here. The real risk class is data loss / unintended destruction (the Critical items above), which I'd treat with the same seriousness.
Artificer laws
- Kernighan's Law — "debugging is twice as hard as writing it." The
awk | sed | tr | grepparsing pipeline is exactly the kind of clever text-munging that's hard to get right; the--porcelainroute is both simpler and correct. - Merged-as-proxy-for-safe (Goodhart-flavored) — "branch is merged" is being used as a proxy for "safe to delete the worktree," but the two diverge precisely when there's uncommitted local state. Guard the real property (clean worktree), not the proxy.
Verdict: 🔧 Changes requested. The feature is worth landing, but destructive logic needs to be safe-by-default first. Highest priorities: (1) never --force away a dirty worktree, (2) never remove the active/primary worktree, (3) resolve the real default branch, (4) parse via --porcelain. After the rebase onto gsd-core/workflows/cleanup.md + changeset, I'm happy to do a fast re-review. Really appreciate you tackling this — the dry-run design shows you're aiming at the right target.
|
Hey Roland, worktree cleanup is a real pain point and your dry run plus safe delete instincts are spot on. Wanted to raise one thing before you sink time into the rebase, because the tree has moved a lot since this branch was cut and it changes the shape of the work. Since this PR was opened, the repo grew a dedicated Worktree Safety Policy Module at
It is even already exposed as a command: Here is the gap though, and why I am not sure this should just be closed: the So my suggestion: rather than rebasing the bash implementation and re-fixing the four data loss issues by hand, the lighter and safer path is to wire the existing Two questions for you and the maintainers:
Either way the original instinct here was right, it is mostly that the safe machinery landed in parallel. |
trek-e
left a comment
There was a problem hiding this comment.
Summary
This PR adds a prune_orphaned_worktrees step to get-shit-done/workflows/cleanup.md so that /gsd-cleanup removes git worktrees whose branch is merged into main. The motivation (worktrees accumulating indefinitely) is real and worth solving. However, the change cannot be accepted in its current form: it reverses an Accepted ADR decision and bypasses the established worktree-safety module seam, makes a destructive git worktree remove --force the default behavior, and fails several hard contribution gates (issue link, ADR, documentation, changeset). Requesting changes.
Per the review directive, the control flow stops at the failed issue-link gate (§0.3) and ADR gate (§2B.1), so the downstream /security-review, /adversarial-review, /code-review, and /rubber-duck passes were not run — the gate blockers below are dispositive. The technical findings (§Findings) are included so they can be addressed in the same revision.
Classification & gate compliance
- Track: Feature (
feat(cleanup):). The behavior is also governed by an existing Accepted ADR, so the enhancement-track ADR rule applies too. - Issue link — FAIL (blocker). PR body contains no
closes/fixes/resolves #<issue>.CI.GATE.issue-link-required=hard-fail if PR body lacks closes/fixes/resolves #<issue>. - Classification label — FAIL. PR has no labels; there is no
approved-feature/approved-enhancement, and no linked issue to verify approval.RULESET.CONTRIB.CLASSIFY.feature=requires approved-feature before implementation. - ADR gate — FAIL (blocker). This behavior is owned by
docs/adr/0004-worktree-workstream-seam-module.md(Status: Accepted). No ADR addendum is included or signed off. Per §2B.1, an existing ADR covering the changed behavior requires a maintainer-signed addendum before review proceeds. - Documentation gate — FAIL (hard blocker). No
docs/files changed.docs/COMMANDS.md/gsd-cleanup(around line 809) still describes only phase archival + branch pruning.RULESET.PR-FLOW.docs-by-changeset-type(changed command behavior →docs/USER-GUIDE.md+docs/COMMANDS.md). - Changeset — FAIL. No fragment under
.changeset/and nono-changeloglabel (CI.GATE.changeset-lint). Afeatneeds anAddedfragment. - Branch naming — FAIL.
feat/worktree-cleanupviolatesRULESET.CONTRIB.BRANCH.format=branch must be <prefix>/NNN-slug(no issue number). - PR template — FAIL. Body uses the codesmith Problem/What/Test-plan layout, not the repo
.github/PR template (PRED.k328.audit-list). - Scope: OK — single concern.
- Competing PRs: none (only this PR references worktree cleanup).
Findings by severity
Blocker
-
Reverses Accepted ADR-0004 + bypasses the canonical worktree seam.
ADR-0004 states: "Worktree metadata cleanup remains non-destructive by default:pruneOrphanedWorktreesrunsgit worktree pruneonly and does not remove linked worktree directories." CONTEXT.md reinforces this:WORKTREE.SEAM.default-prune-policy=metadata_prune_only (non-destructive)WORKTREE.SEAM.decision-1=retain non-destructive default; destructive path only as explicit future opt-in scaffold
This PR makes destructive
git worktree remove --forcethe default behavior of/gsd-cleanup— a direct reversal of an Accepted ADR decision, without an ADR addendum. The canonical seam is the Worktree Safety Policy Module (src/worktree-safety.cts→get-shit-done/bin/lib/worktree-safety.cjs), which already exposesinspectWorktreeHealth(orphan + stale detection),planWorktreePrune,executeWorktreePrunePlan,parseWorktreePorcelain, andsnapshotWorktreeInventory, all with bounded git subprocess calls that returngit_timed_outinstead of throwing. The PR reimplements orphan detection with ad-hoc bash, violatingWORKTREE.SEAM.caller-rule=...no ad-hoc porcelain parsing in callersand the safety invariantWORKTREE.SEAM.invariant=parser failure must degrade to metadata_prune_only and never escalate to destructive removal(the ad-hoc pipeline, on a parse miss, still proceeds togit worktree remove --force). -
Data-loss risk in
git worktree remove --force.
The step note claims: "--force handles worktrees that have uncommitted changes but are confirmed merged — the branch content is already in main." This reasoning is incorrect. "Branch merged into main" means the committed history is in main; it says nothing about uncommitted, unstaged, or untracked changes in the worktree's working directory.git worktree remove --forcedeletes those irrecoverably. A worktree on a merged branch can still hold un-pushed local edits. -
Missing issue link (
CI.GATE.issue-link-required) — see gate table. -
Missing documentation (hard blocker, §2B.2). The new destructive capability is undocumented end-to-end. Please use the
/writing-documentation-with-diataxisskill: this needs reference coverage (updatedocs/COMMANDS.md/gsd-cleanupentry) and a how-to (e.g.docs/USER-GUIDE.md: how to prune orphaned worktrees, including the safety/confirmation semantics). Keep each doc to a single Diátaxis mode and don't mix them. Framework reference: https://diataxis.fr/ -
Missing/unsigned ADR addendum (§2B.1) — see gate table.
Major
-
git worktree list --porcelainis the documented script interface; this scrapes human output (§4).
git worktree list | tail -n +2 | awk '{print $1, $3}' | tr -d '[]'parses the human-readable format, which git documents as unstable for scripts. Usegit worktree list --porcelain(the seam'sparseWorktreePorcelainalready does this). The current parse breaks on detached-HEAD worktrees ((detached HEAD)→$3='(detached'), paths with spaces, and locked/prunable entries. -
Hardcoded
git branch --merged main; repo default branch isnext.
git branch --merged main 2>/dev/nullreturns nothing whenmaindoesn't exist (the error is silenced), so onnext/master-default repos no orphans are detected — or, worse, mis-evaluated. The protected-name list already includesnext, which shows awareness, yet the merge base stays hardcoded tomain. Resolve the default branch dynamically (the seam centralizes worktree-root/git-dir logic viaresolveWorktreeRoot). -
Unbounded git subprocesses reintroduce
DEFECT.UNBOUNDED-SUBPROCESS("git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network"). The seam bounds every git call and degrades togit_timed_out; the ad-hoc bash has no timeout. -
Windows portability. The entire step is POSIX bash (
awk/sed/tr/tail/grep -qx/while read). This file already carries a "Text mode for non-Claude runtimes (Codex, Gemini)" note, so cross-platform/non-bash runtimes are in scope; this step will not run on Windows or in those runtimes.
Minor
- No test/regression coverage. Worktree-pruning logic has test anchors (
tests/worktree-safety.test.cjs,tests/orphan-worktree-detection.test.cjs); routing through the seam would let this behavior be pinned there.WORKTREE.SEAM.test-policy=cover all decision branches in policy module before changing prune behavior.
Nit
- The PR's own Test plan checkboxes are all unchecked — no evidence the manual plan was executed.
What's right
- Using
git worktree remove/git worktree prune(documented git commands) rather thanrm -rfof.git/worktrees/*is the correct primitive choice — the issue is the destructive--forcedefault and bypassing the safety module, not the command selection. git branch -d(lowercase) as a merge safety net is a reasonable touch.
Recommended path forward
- File/link an issue and get an
approved-enhancement(this touches an Accepted ADR) before implementation. - Add a maintainer-signed addendum to ADR-0004 deciding whether destructive directory removal becomes a default or an explicit opt-in (
WORKTREE.SEAM.decision-1framed it as opt-in scaffold). - Implement detection/removal through the Worktree Safety Policy Module (
inspectWorktreeHealth+planWorktreePrune/executeWorktreePrunePlan), not ad-hoc workflow bash — preserving bounded timeouts, porcelain parsing, and the degrade-to-metadata-prune invariant. - Drop
--forceas default, or gate it behind explicit confirmation that surfaces uncommitted/untracked changes. - Add Diátaxis docs, a changeset fragment, a conforming branch name, and the repo PR template.
Verdict
Request changes. Multiple hard-gate failures (issue link, ADR/addendum, documentation, changeset) plus a destructive default that reverses Accepted ADR-0004 and bypasses the worktree-safety seam.
Problem
GSD creates git worktrees automatically during phase execution but never cleans them up. Worktrees accumulate indefinitely across projects. One vault I work in had 41 registered worktrees with 32 branches already merged into main — none of them were ever removed because there was no cleanup path.
gsd-cleanupalready handles phase directory archival and stale branch pruning. Worktrees are the missing third category.What this changes
Extends
get-shit-done/workflows/cleanup.mdwith aprune_orphaned_worktreesstep:Detection (in
show_dry_run):git worktree listgit branch --merged mainto identify branches that are safe to deletegit worktree prune --dry-runto surface metadata-only stubs (directories already gone)Execution (new
prune_orphaned_worktreesstep, runs afterprune_local_branches):git worktree remove <path> --force— removes the directory and.git/worktrees/<name>metadatagit branch -d <branch>— deletes the local branch (lowercase-drefuses unmerged branches as a safety net)git worktree prune— cleans any remaining dangling metadata stubsConfirmation gate: Extended the existing
AskUserQuestionto cover all three actions (archive phases, prune branches, remove worktrees) in a single confirm.Definition of "orphaned"
A worktree is considered orphaned if its branch appears in
git branch --merged main(excluding protected names: main, trunk, develop, next). This is the same standard used for branch pruning in the existing workflow.Test plan
gsd-cleanupin a repo with merged worktrees — verify dry-run shows the orphansgit worktree listafter cleanup — verify only non-merged worktrees remain🤖 Generated with Claude Code
via Happy
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.