Skip to content

wt 0.2.0: worktree lifecycle — new, switch, list, rm, merge - #1

Open
VSN2015 wants to merge 15 commits into
masterfrom
worktree-worktree-lifecycle
Open

wt 0.2.0: worktree lifecycle — new, switch, list, rm, merge#1
VSN2015 wants to merge 15 commits into
masterfrom
worktree-worktree-lifecycle

Conversation

@VSN2015

@VSN2015 VSN2015 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Adds a worktree-lifecycle layer to wt, so a worktree can be created, found, merged, and fully torn down — reclaiming the server, database, Redis slot, and state files the kit provisioned for it.

Inspired by worktrunk, which solves the complementary half: it creates and destroys worktrees but knows nothing about databases, servers, or isolation, so its remove leaks every resource wt provisions.

New commands

wt new <branch> [--from <base>] [--server]     create a branch + worktree, run the prepare hook
wt switch [<branch>]                            cd to a worktree; no argument opens a picker
wt list [--all]                                 branch, slug, status, server, isolation
wt rm [<branch>] [--keep-branch] [--force]      full teardown, then remove
wt merge [<branch>] [--into <trunk>] [-m <msg>] [--no-remove] [--force]
wt shell-init [bash|zsh|fish]                   the shell function that makes switch cd

Plus wt doctor checks for the path template, fzf, and shell integration; worktrees.path/worktrees.trunk and isolation.db_drop/isolation.redis_flush across all ten stack templates; README documentation; and a bump to 0.2.0.

Design

  • The lifecycle commands need only git. require_config is split into load_config_soft (never exits, sets HAS_CONFIG) and the existing strict wrapper. The ten pre-existing commands keep the strict wrapper and behave identically — verified by tracing every call site.
  • Stdout is a dedicated "cd channel". Lifecycle commands print a path there and nothing else; all human output goes to stderr, so the shell wrapper can cd to it.
  • The path template's last segment becomes the slug, and the slug determines the port, the Redis DB number, and the database name. Slug derivation itself is unchanged, so existing worktrees keep their identity.
  • Lookup is always git worktree list --porcelain, never a recomputed template — so it finds worktrees created by hand or by other tooling.

Safety

wt rm and wt merge stop servers, drop databases, flush Redis slots, and delete branches. wt merge also rewrites history and does not prompt.

  • wt merge writes refs/wt/premerge/<slug> before the first mutation; every failure path restores the branch and names that ref.
  • Rebase conflicts force the branch ref back explicitly — a mid-rebase HEAD is detached, so rebase --abort alone is not enough.
  • Trunk advances via --ff-only, so a merge commit is structurally unreachable.
  • A database is dropped only when a .dbowned provenance marker proves the kit bootstrapped it. Databases merely adopted by db_check — including every database created before 0.2.0 — are left in place with a notice. This errs toward keeping data.

Testing

New test/lifecycle.sh: 110 assertions, git-only (no docker, no YAML backend required). Runs on macOS and is wired into the Debian and Alpine container suites.

Verified on macOS under both the system shell and dash, including dash executing bin/wt itself.

Not yet verified: the container suites (./test/linux.sh) could not run — no docker daemon on the development machine. CONTEXT.md §5 records that three of this codebase's eight historical bugs surfaced under BusyBox ash first. Please run ./test/linux.sh before merging. Two tests derive a docker-free PATH and are the most likely to need adjustment on a Linux layout.

Also unexercised: both picker branches (fzf is not installed; the sandbox has no controlling terminal) and the emitted fish wrapper.

Note on docs/

Commits 18c11f5 and f936d19 add docs/superpowers/{specs,plans}/ — the design spec and implementation plan, ~1,900 lines. These were previously untracked. If you would rather keep them out of the repo, they can be dropped without touching any code.

Review pointers

The changes most worth a careful look:

  • remove_worktree_and_branch and teardown_worktree (bin/wt) — shared by both destructive commands; teardown borrows and restores the SLUG/N/WT_PATH globals deliberately.
  • cmd_merge's failure paths — the restore ordering is load-bearing.
  • expand_path's _esc helper — path values become sed replacement text, where & would otherwise mean "the whole match".
  • ensure_own_db — the one change to pre-existing code, adding the provenance marker.

🤖 Generated with Claude Code

VSN2015 and others added 15 commits August 29, 2026 16:13
need docker lived inside load_config_soft's config-present branch, so
any repo with a worktree-kit.yml — even one that just omits runner:,
which defaults to compose — made wt list die on a machine without the
docker CLI. That broke the premise of the lifecycle layer: those
commands need only git. Moved the check into require_config, after the
HAS_CONFIG gate, so load_config_soft genuinely never exits and the ten
pre-existing commands keep identical behavior (they all reach the check
through require_config). Added a regression test that scrubs docker
from PATH against a compose-defaulting config and asserts wt list still
works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cmd_new handed the prepare hook to `wt run` as its payload, but `wt run`
already runs hooks.prepare itself before execing its payload — running
the hook twice on every `wt new`, on both runners. `wt new --server`
made it three times, since `wt server` independently runs the hook as
part of its own command string.

Pass an inert `true` payload instead (wt run's own prepare step does
the work), and skip the standalone prepare step entirely when --server
is requested so `wt server`'s prepare run is the only one.
… removal

Five review findings on the destructive path: the unmerged-commits guard
failed open when a configured trunk did not resolve in the worktree; wt rm
from inside the worktree being removed crashed after git worktree remove
deleted the cwd, stranding the branch and skipping emit_cd; a failed
server-stop was silently swallowed instead of reported; .dbready alone did
not distinguish a kit-bootstrapped database from a merely-adopted one, so
wt rm (and soon wt merge) could drop a hand-created database — added a
narrower .dbowned marker and gated the drop on it; and the whole
HAS_CONFIG=1 teardown branch (server-stop, the ownership gate, db_drop,
redis_flush, the SLUG/N borrow) had zero test coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cmd_reset cleared .dbready but left a stale .dbowned behind. If the
database is later replaced by hand and db_check adopts the replacement
(touching only .dbready by design), the stale .dbowned would wrongly
authorize wt rm/merge to drop a database this incarnation of the slug
never created. Reset now clears both markers together; no heuristic
added to recover ownership after a reset, per instruction — the
accepted tradeoff is a "left in place" notice instead of a drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d, untested guards covered

- Rebase-conflict abort no longer trusts "rebase --abort" alone: falls back
  to checkout -f + an explicit update-ref of refs/heads/<branch> before the
  reset --hard, so a mid-rebase detached HEAD can never leave the branch
  ref silently rewritten while die claims it was restored.
- Guarded the reset --hard fallbacks in the squash-commit-failure and
  fast-forward-failure paths so a failing reset can't have set -e kill the
  function before its recovery message (and the backup ref) reach the user.
- Added cmd_rm's trunk-resolution guard (rev-parse --verify --quiet) so an
  unresolvable configured trunk dies with "cannot verify" instead of
  silently reading as "no commits ahead".
- Test: feat/conflict now gets two commits so the squash always changes
  history shape, closing a same-second-timestamp coincidence that let the
  restore assertion pass even with the restore code deleted; added a
  rev-list --count shape assertion as the load-bearing check.
- Test: backup ref assertion now checks value and timing (equals the
  pre-squash HEAD), not just that the ref name exists.
- Test: added coverage for the trunk-not-at-primary and
  upstream-without---force guards, with master/branch "untouched"
  assertions rather than bare assert_fails — needed because both guards,
  when disabled, can still exit nonzero for unrelated incidental reasons
  (a fast-forward into the wrong branch, or git's own branch -d safety
  check) while real damage already happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_drop

- test/lifecycle.sh: the "yields a space" doctor assertion matched the
  fixture's own path text regardless of whether the WARN line fired
  (cmd_doctor echoes the raw template unconditionally before the check
  runs). Assert on "wt new will refuse it" instead, which only the WARN
  line can produce. Verified load-bearing (RED/GREEN) along with the
  branch-unique assertion, which was already sound.
- templates/compose/django.yml: comment out db_drop — its db_bootstrap and
  own_db_env reveal no connection details at all, so the db/postgres host
  guess risked DROP DATABASE against the wrong server. redis_flush stays
  active.
- templates/{compose,host}/laravel.yml: note that the redis_flush host is
  an inference (this template exports REDIS_DB, not a REDIS_URL).
- bin/wt: usage() said shell-init "makes switch cd"; the wrapper actually
  intercepts switch/new/rm/merge.
…t aborts diagnosable

Root cause of a flake (1-in-5 observed): remove_worktree_and_branch was
called with force=0, so branch teardown used plain "git branch -d". After
a squash+rebase, the branch's tip is a new commit object, and git's -d
refuses to delete a branch that is merged into HEAD but not merged into
its own configured upstream tracking ref — a real but redundant check
here, since a successful --ff-only merge already proves every commit is
reachable from trunk. Whether that check fired depended on a same-second
commit-timestamp coincidence in the squash, and when it fired, "wt merge"
died under set -e; since the test's "wt merge ... --force" call was a bare
statement, that nonzero exit killed the whole suite silently — no FAIL
line, no LIFECYCLE FAIL, just truncated output.

- cmd_merge now passes force=1 to remove_worktree_and_branch (git branch
  -D), with a comment explaining why this is safe and warning against
  reverting it. Accepted consequence: worktree removal also becomes
  --force (discards untracked files) — not routed around, since refusing
  at this point would strand the user with trunk already advanced and the
  database already dropped.
- Wrapped every bare "$WT" new/merge invocation in the Task 5 test block
  (not already inside assert_fails) with "|| fail ...", so a future
  regression produces a visible FAIL line and reaches LIFECYCLE FAIL
  instead of silently truncating the run.

Verified: reverting force=1 alone reproduced the flake immediately (now
failing loudly instead of silently); with both fixes applied, 30
consecutive full-suite runs plus 4 dash runs, all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emoval

Whole-branch review of the lifecycle layer found seven defects that the
per-task reviews could not see, because each only spanned one command.

bin/wt:

- remove_worktree_and_branch ran four bare git calls AFTER teardown had
  already stopped the server, dropped the database and deleted every state
  file. A failure there aborted under set -e with only git's stderr: no
  "removed" line, no emit_cd, so a shell-wrapper caller was left in a
  deleted directory. The worktree removal now dies with a message saying
  the reclaim already happened; branch deletion only notes. The reachable
  trigger was `git branch -d`, which asks "merged into HEAD?" — not the
  "merged into trunk?" question cmd_rm already answered — so removing a
  sibling worktree could strand a branch wt had verified.

- teardown_worktree borrowed SLUG and N for the target but not WT_PATH,
  which is what runner_exec keys off. hooks.prepare therefore re-ran in the
  CALLER's worktree on every rm/merge of another worktree, and db_drop /
  redis_flush resolved relative paths against the wrong tree. The unused
  _tp parameter was the tell.

- expand_path fed {parent}, {repo}, {branch_raw} and {home} to sed as
  replacement text, where & means the whole match. The default template
  uses two of them, so a repo directory named "R&D-app" silently expanded
  {repo} to "R{repo}D-app" — and that path's last segment names the slug,
  the port, the redis slot and the database.

- $HOME was dereferenced unguarded under set -u in the {home} expansion and
  the rc-file scan; an unset HOME (env -i, some CI runners) died bare.

- The fish wrapper used a single-command variable override, a syntax error
  on older fish, which would break wt entirely for those users.

- The no-.dbowned notice claimed the database was "adopted, not created by
  wt". That is false for every worktree provisioned before this release —
  .dbowned did not exist — which is exactly who sees it first on upgrade.

- A failed restore in cmd_merge printed "could not restore" and then died
  saying "restored to <sha>", on the one path where the user is panicking.
  One helper now prints a single, true outcome.

test/lifecycle.sh (84 -> 110 assertions):

- Both PATH=/usr/bin:/bin scrubs would have misbehaved in the container
  suites: docker lives in /usr/bin on Linux, inverting the server-stop
  assertion, and the truncation can remove the YAML backend. They now drop
  only directories that hold docker, shimming git and the backend first.

- Guarded ~20 setup calls and captures that ran bare under set -eu, so a
  regression FAILs with a diagnostic instead of truncating the run.

- First coverage of worktrees.path from the committed config (only the
  .local path was exercised); stdout purity for rm and merge; the reason
  behind the three refusals that matter; a real rebase-state check; and the
  removal of an assertion that had no code path able to fail it.

Docs: README spells out that removal deletes the whole directory including
gitignored files, that merge tears down like `rm --force` rather than `rm`,
that hyphens fold in {branch}, and how pre-0.2.0 databases behave. The
design spec is amended in three places where the implementation knowingly
diverged, each marked as a resolved deviation.

Verified on /bin/sh and dash, six consecutive runs, 110 assertions, no
flake. test/linux.sh not run — docker daemon is down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anup

The db_drop bullet told upgrading users that `wt reset <slug>` plus an
--own-db cycle would re-bootstrap and re-mark a pre-0.2.0 database. It
cannot: reset clears both markers, but ensure_own_db then runs db_check,
which succeeds because the database still exists, and returns having
touched only .dbready. .dbowned is written solely on the db_bootstrap
path, which runs only when the database is missing. The bullet therefore
contradicted its own case (b) two lines further down. It now says to drop
the database by hand first, and explains why reset alone is not enough.

Also amends spec §10.1 step 7, which still listed four state files while
the implementation removes .dbowned as well — a fourth divergence, marked
like the other three, and the status header updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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