Skip to content

feat(cmd): make destroy safe by default#37

Merged
kunchenguid merged 9 commits into
mainfrom
fm/th-destroy-v2
Jun 24, 2026
Merged

feat(cmd): make destroy safe by default#37
kunchenguid merged 9 commits into
mainfrom
fm/th-destroy-v2

Conversation

@kunchenguid

Copy link
Copy Markdown
Owner

Intent

Redesign the 'treehouse destroy' command to be safe-by-default as a BREAKING CHANGE that release-please bumps to v2.0.0 (committed with a 'feat!:' subject + 'BREAKING CHANGE:' footer; versions are managed by release-please and must NOT be hand-edited). Background: the old destroy was dangerous - 'destroy --all --force' deleted immediately with no preview, --all's scope was unclear, and --force overrode every protection at once, which wiped leased/reserved homes in a real incident. The goal was to bring destroy up to prune's safety bar while keeping it the deliberate 'remove this even though it has unlanded work' tool.

Decisions implemented (all intentional):

  1. Narrow explicit targets: 'destroy ' removes exactly one worktree; 'destroy --all' removes worktrees in THAT pool only. There is deliberately NO cross-pool/global destroy; '--all' with no pool target is an error (this is a chosen safety constraint, not a missing feature).
  2. Dry-run by default, exactly like prune. Nothing is removed without --yes.
  3. The blunt --force flag is REMOVED entirely (this is the breaking part). It is replaced by per-risk opt-in flags, each gating one risk class: --include-unlanded (uncommitted OR HEAD-not-merged), --include-in-use (running process; processes are terminated cleanly first), and --include-leased (leased; honored ONLY when the exact path is named, and NEVER via --all - combining --include-leased with --all is intentionally rejected with an error). A bare 'destroy --all --yes' removes only the genuinely disposable set (merged, clean, idle, unleased - the same set prune takes) and SKIPS the rest, naming the flag that would include each.
  4. Risk-revealing preview: per-worktree status tag ([disposable]/[leased]/[in-use:]/[unmerged]/[dirty]/[unverified]), path, and size, plus an honest summary; it never prints a blind 'all worktrees destroyed'.
  5. Honest exit codes: a single named target that is skipped for lack of a flag exits non-zero so scripts notice; bulk --all skipping unsafe worktrees is normal and exits 0.

Architecture choices: destroy and prune share classification primitives (new classifyForDestroy in internal/pool/destroy.go reuses prune's ownerAlive/process scan/backingRepositoryMissing/git.IsDirty/git.IsHeadMergedIntoRef against the same resolvePruneDefaultRef ref) so they agree on leased/in-use/unlanded/disposable. New entry points pool.DestroyWorktree (single path, allowLeased=true) and pool.DestroyPool (bulk, allowLeased=false) intentionally REPLACE the old exported pool.Destroy/pool.DestroyAll and the now-unused worktreeInUse helper, which were removed; the well-tested two-phase reservation + pre_destroy hook safety (a worktree re-acquired during its hook is never deleted) is preserved. The pool-level functions return classified results and never error on skips; only the cmd layer maps a single-target no-op to a non-zero exit. The 'unverified' class (orphaned/cannot-verify) is intentionally gated like unlanded. The pool/cmd destroy tests were rewritten for the new API and e2e tests added for dry-run default, lease protection under --all, per-risk flags, scoped --all, and confirmation gating. Docs (README + AGENTS.md) updated with the new surface and a --force -> --include-* migration table.

What Changed

  • Redesigns treehouse destroy around dry-run-by-default execution, explicit single-worktree or pool-scoped --all targets, and honest skip behavior for unsafe candidates.
  • Removes the blunt --force path and adds per-risk opt-ins for unlanded, in-use, and leased worktrees, with leased destruction limited to exact path targets.
  • Moves destroy planning into internal/pool, sharing prune-style safety classification and reservation checks, and updates the CLI, docs, and tests for the new destroy surface.

Risk Assessment

⚠️ Medium: No material issues found, but the change is a broad rewrite of destructive worktree deletion paths, so residual risk is higher than a narrow fix.

Testing

Verified the branch has a feat! commit with a BREAKING CHANGE footer, ran the full Go test suite successfully, and captured a manual CLI transcript showing rejected global --all, removed --force, dry-run preservation, lease protection in bulk destroy, named leased removal, dirty gating with non-zero single-target exit, --include-unlanded removal, and pool-scoped --all preserving another repo's worktree.

Evidence: Release-please breaking-change signal
e4bc73b4e292c87bda9e9b82d868994b4b2ad575
feat!: redesign destroy to be safe-by-default
BREAKING CHANGE: the `treehouse destroy --force` flag is removed.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed (4) ✅
  • 🚨 internal/pool/destroy.go:218 - Removal is authorized from a single DestroyClass, but classifyForDestroy returns at the first risk. A dirty or unmerged worktree that is also leased or in-use can be destroyed with only --include-leased or --include-in-use, bypassing --include-unlanded; a leased worktree with live processes also bypasses process termination. Track independent risks and require every matching --include-* flag before planning removal.
  • 🚨 internal/pool/destroy.go:335 - The reservation pass trusts the stale plan and overwrites the current state entry without rechecking owner, lease, dirtiness, or merge state. If another get or lease acquires a planned disposable worktree before this lock is taken, destroy can replace that reservation and delete the active worktree. Reclassify under the lock before setting Destroying and skip anything no longer allowed.
  • 🚨 internal/pool/destroy.go:384 - After pre_destroy hooks, deletion only checks sameDestroyReservation. A hook or concurrent process can add uncommitted files, move HEAD, or start a process after planning, and a bare --yes still force-removes the worktree. Mirror prune's final safety check just before removal and skip with the reservation cleared when the current risks are no longer allowed.
  • ⚠️ internal/pool/destroy.go:412 - removeManagedWorktree drops both git worktree remove and os.RemoveAll errors, while executeDestroy still reports the target as destroyed and removes it from state. A failed deletion, especially from locked files on Windows, can leave content on disk with no managed state entry. Return cleanup errors and keep the entry with its reservation cleared as a skipped cleanup failure.

🔧 Fix: Harden destroy risk checks
1 warning still open:

  • ⚠️ internal/pool/destroy.go:432 - The destroy path treats a nil return from TerminateWorktreeProcesses as proof that the worktree was drained, but that helper only reports scan errors and intentionally swallows kill failures while filtering the current process and its parents. A surviving process can therefore remain in the worktree while destroy removes it and reports success; re-scan after termination and skip removal if any processes are still present.

🔧 Fix: Guard destroy against process survivors
1 error still open:

  • 🚨 internal/pool/destroy.go:373 - Destroy reservations overwrite an existing live owner reservation, but the later skip paths only clear the destroy reservation. If --include-in-use reserves an owner-held worktree and a hook dirties it, a process survives, or removal fails, the worktree is left in state with OwnerPID/OwnerStartedAt cleared, so a later get can hand out a worktree still owned by the original agent. Preserve the previous owner reservation and restore it whenever destruction does not complete.

🔧 Fix: Restore destroy owner reservations on skips
3 warnings still open:

  • ⚠️ cmd/destroy.go:163 - --all falls back to git.FindRepoRootFrom, which returns the linked worktree root when the target is inside a managed worktree. For local-only repos or relative root configs, config.ResolvePoolDir then computes a different pool under that worktree, so documented commands like treehouse destroy . --all --yes from a subdirectory can report nothing destroyed while locking the wrong pool. Use git.FindMainRepoRootFrom here, matching return's resolver.
  • ⚠️ internal/pool/destroy.go:272 - A process enumeration failure is classified only as DestroyUnverified, so --include-unlanded --yes authorizes deletion and the final removal path never enters the in-use termination branch. If process scanning fails, destroy can remove a worktree without proving it is idle or draining processes. Treat process scan failure as an in-use blocker, or skip unless the process state can be rechecked successfully.
  • ⚠️ internal/pool/destroy.go:512 - When repoRoot is empty but the backing repository is present, removeManagedWorktree skips git worktree remove and still deletes the container directory. This can happen if a pool is planned while all entries look orphaned and the backing repo reappears before final deletion, leaving stale worktree registrations in git. Re-resolve the repo root at removal time or refuse non-orphan removal without one.

🔧 Fix: Harden destroy safety resolution
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • git log --format='%H%n%s%n%b%n---ENDCOMMIT---' 67f31cecf29e42a40ff8565336f4d8d5d4b6b2d2..973bcccac4244e9f9734ac4c164c83e9f41f3d85
  • go test ./...
  • Manual CLI E2E using a built treehouse binary against isolated git repos, recorded in .no-mistakes/evidence/fm/th-destroy-v2/destroy-v2-cli-transcript.txt
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Destroy was dangerous: `destroy --all --force` deleted immediately with no
preview, --all's scope was unclear, and --force overrode every protection at
once (this wiped leased/reserved homes in a real incident). Bring destroy up to
prune's safety bar while keeping it the deliberate "remove this even though it
has unlanded work" tool.

- Narrow, explicit targets: `destroy <path>` removes exactly one worktree;
  `destroy <pool> --all` removes that pool only. There is no cross-pool or
  global destroy, and --all with no pool target is an error.
- Dry-run by default, like prune. --yes is required to execute.
- Replace the blunt --force with per-risk opt-in flags: --include-unlanded
  (uncommitted or unmerged), --include-in-use (running process; terminated
  cleanly first), and --include-leased (leased, only when the exact path is
  named, NEVER via --all). A bare `--all --yes` removes only the disposable set
  and skips the rest, naming the flag that would include each.
- Risk-revealing preview: a status tag, path, and size per worktree, plus an
  honest summary of what was destroyed and skipped (never a blind "all
  worktrees destroyed").
- A single named target that is skipped for lack of a flag exits non-zero.
- Destroy and prune share classification primitives (classifyForDestroy) so
  they agree on leased / in-use / unlanded / disposable.

BREAKING CHANGE: the `treehouse destroy --force` flag is removed. Replace it
with the specific --include-unlanded / --include-in-use / --include-leased
flag(s) for the risk you intend to override, plus --yes, and pass an explicit
pool path to --all.
@kunchenguid kunchenguid merged commit 0190382 into main Jun 24, 2026
4 checks passed
@kunchenguid kunchenguid deleted the fm/th-destroy-v2 branch June 24, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant