✨ feat(tui): run the removal lifecycle behind the d key - #526
Conversation
`gwm remove` runs the pre_remove / post_remove hooks and records an undo-journal entry; the TUI `d` calls `worktree::remove` directly and does neither. Four tests state the contract through the App state machine, all red today: - a delete writes the journal entry `gwm undo` needs - both remove hooks run - a refused pre_remove keeps the worktree, and nothing downstream runs - running hooks out of `.gwm.toml` is gated on the TOFU ledger Plus one that already passes, as a net: the batch removes the path the confirm snapshot named, not whatever the id resolves to at fire time (the `remove_verified` guard from PR #520, which re-resolving the target to build a journal entry would otherwise make vacuous). Its own test binary because these rewrite `GWM_HISTORY_FILE`, and the #507 env guard derives who must serialise from a transitive walk of `src/` — a `set_var` in `tui_app_tests.rs` would implicate every delete test there. refs #521
`cli::remove_one` owned the whole sequence — pre_remove hooks, undo journal, destruction, post_remove hooks — interleaved with its printing, so the only way to reach it was through the binary. It moves to `removal::remove_with_lifecycle`, which prints nothing and hands back what each phase produced; `remove_one` becomes the renderer. Three pieces make that callable from a TUI worker thread: - `lifecycle::run_phase_quiet` is the phase runner without the abort-time `print_report`, returning the partial report alongside the error. `run_phase` is now a wrapper that prints and unwraps it, so all twelve existing call sites keep their behaviour to the character. - `lifecycle::has_steps` answers whether one phase has anything to run, which is what a trust gate should ask rather than whether the whole `[hooks]` table is non-empty. - `trust::evaluate_silent` is the no-prompt decision the TUI already open-coded for bootstrap, now shared, with the approving command as a parameter. `expected_path` is a separate argument from `found.path` rather than derived from it: a caller that re-resolves its target between the decision and the call — which the TUI worker must, to name the branch in its journal entry — would otherwise compare live state with itself, and `remove_verified` could never refuse. One behaviour change on the CLI: the journal entry is written after the removal succeeds instead of before it. The branch OID is still captured beforehand, which is what #29 needed; what changes is that a refused removal no longer appears in `gwm history` as something `gwm undo` would replay. refs #521
The delete worker called `worktree::remove` and nothing else. It now goes through `removal::remove_with_lifecycle`, so a TUI delete runs `pre_remove` / `post_remove` and lands in the undo journal, the same as `gwm remove`. `gwm undo` can put back what `d` removed. Per repo rather than per target: the config, the worktree list and the trust decision are resolved once for each distinct workdir a batch spans, so ten rows in one repo no longer mean ten `worktree::list` calls each walking every worktree's git status. The batch still never stops at the first error. A `pre_remove` that refuses fails that target and the rest carries on, which is the loop `cmd_remove` already ran. What is new is the trust gate: running a hook executes code out of `.gwm.toml`, so a repo whose config defines remove hooks is checked against the TOFU ledger (#95) and refuses when it is not approved, since the alternate screen has nowhere to host the prompt. The gate asks `lifecycle::has_steps` about the two remove phases only, so a config whose hooks are all `post_create` executes nothing on a delete and is never asked — without that, `d` would start refusing where it has always worked. `DeleteBatchOutcome` grows a `warnings` list for what goes wrong around a removal that happened anyway: a `post_remove` that aborts, a journal entry that could not be written. Those ride the status line and stay out of `failure_banner`, which is what holds the confirm overlay open — counting them as failures would report the opposite of what is on disk and offer to remove a row that no longer exists. Hook stdout and stderr are captured by `command_log::run_logged`, so they reach the Command Logs modal rather than painting over the screen. refs #521
The hooks page said `pre_remove` / `post_remove` ran around `gwm remove`, which was accurate and is now half the story. Both pages state what the TUI does with them, that there is no `--skip-hooks` there, and that a repo whose config defines remove hooks goes through the trust ledger first. The `gwm undo` reference gains the two facts a batch raises: undo is per worktree, so ten removals append ten entries and each undo pops one; and the entry is written once the removal succeeded, so a refused target never shows up as something to replay. EN and FR both. refs #521
`RepoBatch::open` called `Config::load_for_repo`, which re-resolves `global_config_path()` itself. That is right at runtime and wrong twice otherwise: the hooks a delete runs would come from a different layering than the one the config panel shows, and tests that build the App with `new_at_layered(.., None)` — the injection point that exists so they do not read the runner's `~/.config/gwm/config.toml` (#194) — had their worker read it anyway. A global `[[hooks.pre_remove]]` on a maintainer's machine would have turned the new tests red locally and left them green on all three runners, which set `GWM_NO_GLOBAL_CONFIG=1` workflow-wide. The App already keeps `global_path`; the worker takes it. refs #521
The acceptance criterion is a round trip, and asserting the journal entry's fields is not one. `gwm undo` filters entries on `repo_root` verbatim, so an entry whose paths were canonicalised differently than undo resolves them passes every field assertion and is still invisible — and `/var` versus `/private/var` already bit this file once while it was being written. The test drives the delete through the App, then shells out to the real binary with the same `GWM_HISTORY_FILE` and asserts the worktree is back on disk. Deliberately mixed: the App state machine for the delete, the binary for the recovery. Nothing else can answer the question. closes #521
Two of the links added in the previous commit were wrong: `#gwm-trust` is not the anchor the CLI reference generates (`gwm-trust-listrevokeshow-issue-95` is), and `gwm-toml#hooks` pointed at the schema section rather than at the page that explains the ledger. Both now go to `/configuration/trust-ledger`, which is what the sibling links on the same pages already use. refs #521
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
`a_tui_delete_writes_the_undo_journal_entry` compared the recorded path against `doomed.canonicalize()`, which is a different normalisation from the one `Worktree::path()` carries. Green on ubuntu and macos, red on windows-latest only, where canonicalize prepends the `\\?\` verbatim prefix that gwm never produces: left: "C:/Users/runneradmin/.../wt-521-journal" right: "\\?\C:\Users\runneradmin\...\wt-521-journal" The test now reads the path off the listed row before the removal, which is the same source the journal entry gets it from. That is the reason `worktree::remove_verified` compares its two paths verbatim as well, and its doc comment says so. refs #521
Codex review on PR #526, four findings, all four real. **A hook ran against a path the user never confirmed (P1).** The worker re-resolves its target to name the branch in the journal entry, so `found.path` is where the worktree is NOW; `pre_remove` runs with its cwd there, and the mismatch with the confirmed path was only caught afterwards inside `remove_verified`. A destructive hook had already executed. `worktree::verify_path` comes out of `remove_inner` and runs at the top of `remove_with_lifecycle`, before any hook. Both call sites share it, so a moved target is refused with one wording whichever catches it first. On the CLI the two paths are the same object, so the check is a no-op there and the batch behaviour is unchanged. **A removal that failed after mutating had no recovery anchor (P2).** A regression of the ordering change earlier in this PR: `worktree::remove` prunes the admin entry BEFORE deleting the directory (#98), so a filesystem failure mid-removal leaves the repo mutated and, with `--delete-branch`, the branch tip gone. The journal entry moves back before the destructive call but after every refusal that mutates nothing (the path check, `pre_remove`, the trust gate), so both cases are now right: a refusal records nothing, a partial failure keeps its anchor. **`on_fail = "warn"` was silent in the TUI (P2).** It is a success carrying a Warning step; the CLI prints the report, the TUI printed nothing. Warning steps now reach `warnings`, hence the status line. **The trust gate asked the merged config (P2).** The ledger is about the repo's `.gwm.toml`, but `has_steps` was asked of the layered result, so a remove hook out of `~/.config/gwm/config.toml` — the user's own, needing no approval — made an unapproved repo file refuse a delete during which not one of its lines would run. It asks the repo file alone now, which is what the comment above it already claimed. refs #521
Codex review pass 2 on PR #526. Three of the four findings pointed at lines written by pass 1; two of those were regressions I introduced. **The check stopped guarding the handle it was about (P1).** Extracting `verify_path` and calling it from `remove_inner` meant the id was resolved twice: another process removing and recreating it between the two lookups would have the first handle validated and the second one destroyed. That is the window #520 closed, and the doc comment saying so is the one I deleted while doing it. `remove_inner` compares on its own handle again; `verify_path` stays as the advance check only, and the refusal wording is shared so either site reads the same. **The journal named a branch the removal was not deleting (P1).** A batch resolves its listing once, so a `pre_remove` on an earlier target can check out another branch in a later one without moving it: every path check passes, `worktree::remove` deletes the branch it reads off HEAD, and the entry recorded the stale one. `gwm undo` would then restore a ref that was never deleted while the deleted one stayed lost. The entry reads HEAD too, the same source, and yields `None` on a detached HEAD just as the removal does nothing with one. **A hook could invalidate the plan and still get an entry (P2).** The advance check runs before the hooks, so a `pre_remove` that moves its own worktree passed it, the entry was written, and `remove_verified` then refused: the journal claimed a removal that never happened. Checked again after the hooks, before the write. **The config was read twice around an expensive call (P1, narrowed).** `config` and `repo_only` sat on either side of `worktree::list`, which walks every worktree's git status, so a `.gwm.toml` rewritten in between could have the gate answer about a different file than the one whose hooks run. They are read back to back now. Not atomic: `trust::evaluate` opens the file a third time to hash it, and closing that means handing it bytes already read, which changes the trust surface rather than this one. refs #521
Codex review pass 3 on PR #526. `branch_at_removal_time` collapsed "opened the worktree and HEAD is detached" and "could not open the worktree" into the same `None`, then fell back to the listing for both. A `pre_remove` that detaches HEAD therefore produced an entry naming a branch the removal deletes nothing of, and `gwm undo` would recreate the worktree on it instead of reporting a detached-HEAD entry. Detached now yields `None`, matching what `worktree::remove` does with it: it reads the same HEAD, gets a bare OID, finds no branch by that name and deletes none. Only a genuine open or read failure falls back. refs #521
Codex review loop: 3 passes, stopped on convergence
Nine findings fixed across Where it stopped being about the diffPass 2. From there on, every finding pointed at a line written to fix an
Pass 3 was not a dry pass either: it found a real, deterministic defect in the The gravity contrast
The three findings left on the table
One invariant, three check sitesThere are now three path checks, and they are not accumulated paranoia. The
Checked before anything runs (so a hook cannot act on a moved worktree), What is left to validate outside reviewNone of this has run against a real terminal, which is why the next useful
|
Both green on macos, each red on one other runner. **ubuntu**: `the_journal_names_the_branch_the_removal_actually_deletes` drove the branch swap from a sibling target's hook, which assumes the batch reaches that sibling first. `worktree::list` does not sort — the order comes from `.git/worktrees/`, so it is a filesystem detail, and on ubuntu the swapped target was removed before the hook that swaps it ran. The target's own `pre_remove` does the checkout now: same snapshot-versus-HEAD divergence, one target, no ordering assumption. **windows**: `a_hook_that_moves_the_target_leaves_no_journal_entry` had the hook rename the worktree that is its own working directory, and Windows refuses to rename a directory a live process is sitting in (`fatal: failed to move ...: Permission denied`). The hook leaves it first (`cd / && git -C ... worktree move`). Both still go red without their production fix, verified by reverting it. refs #521
`a_hook_that_moves_the_target_leaves_no_journal_entry` had the hook run `git worktree move` on its own target, and Windows refused it (`fatal: failed to move ...: Permission denied`) whether or not the hook left the directory first: libgit2 has just memory-mapped an index out of that worktree while listing it, and a mapped file blocks the rename of its parent. Green on macos and ubuntu, red on windows-latest only. The hook deletes `.git/worktrees/<id>` instead. That invalidates the target exactly as thoroughly for the post-hook check, and touches nothing anyone holds open. Renamed to match what it now does, and it asserts the admin entry is gone before asserting the journal is empty, so the check cannot pass because the hook silently failed. Still red without the post-hook check, verified by deleting it. refs #521
Description
din the TUI calledworktree::removeand nothing else, so it ran no[hooks.pre_remove]/[hooks.post_remove]and wrote no undo-journal entry:an interactive delete was unrecoverable, and a hook written to guard a removal
only held for whoever used the CLI. #484 made that bite harder, since
Spacedcan now take out ten worktrees on one keystroke.The sequence moves out of
cli::remove_oneintoremoval::remove_with_lifecycle,which prints nothing and hands back what each phase produced. The CLI becomes
its renderer; the TUI worker calls it directly. One sequence, two callers.
Closes #521
Type of change
The issue calls itself a refactor. It is not: hooks now run and the journal is
now written on a path where neither happened. Both boxes are ticked because the
extraction underneath genuinely is one.
Changes
removal::remove_with_lifecycleowns the sequence:pre_removehooks,destruction, undo-journal entry,
post_removehooks. No printing.lifecycle::run_phase_quietis the phase runner without the abort-timeprint_report, returning the partial report next to the error.run_phaseis now a wrapper that prints it, so the twelve existing call sites keep their
output to the character.
lifecycle::has_stepsandtrust::evaluate_silent: the phase-level questionthe trust gate should ask, and the no-prompt decision the TUI already
open-coded for bootstrap, now shared.
repo a batch spans rather than once per target.
DeleteBatchOutcomegainswarningsfor what fails around a removal thathappened anyway. Those ride the status line and stay out of
failure_banner, which is what holds the confirm overlay open.gwm undoreference.Behaviour changes worth calling out
dcan now refuse where it always deleted. Running a hook executes codeout of
.gwm.toml, so a repo whose config defines remove hooks goes throughthe TOFU ledger (#95) first, and an unapproved one refuses rather than silently
skipping the guard hook: the alternate screen has nowhere to host the prompt.
The gate asks about the two remove phases only, so a config whose hooks are all
post_createexecutes nothing on a delete and is never asked. Without thatnarrowing this would refuse deletes that have always worked.
A
pre_removethat refuses now refuses in the TUI, for that one target;the batch carries on and the confirm overlay stays open on what failed, which
is the loop
cmd_removealready ran. There is no--skip-hooksin the TUI; todelete past a hook, use the CLI with
--force.On the CLI, the journal entry moved after the destructive call. It used to
be written before, so a target that then got refused still showed up in
gwm historyas somethinggwm undowould replay. The branch OID is stillcaptured beforehand, which is what #29 needed. Pinned by
a_batch_refuses_a_target_whose_path_moved_since_it_was_resolved, which Iprobed red by putting the write back where it was.
undostays per-worktree. A batch of ten appends ten entries and eachgwm undopops one. Giving the journal a notion of a batch is a separatefeature;
gwm remove a b chas behaved this way since #484 and the TUIinherits it rather than creating it.
Tests
cargo testpasses locallycargo fmt --checkpassescargo clippy -- -D warningspassestests/Seven new tests in
tests/removal_tests.rs, four of them red before theimplementation (journal entry, both hooks running, a refused
pre_removestopping everything downstream, the trust gate), plus:
gwm_undo_restores_what_the_tui_deleted: the round trip the acceptancecriterion actually asks for. Asserting the entry's fields is not it, since
undofilters onrepo_rootverbatim, so an entry canonicalised differentlythan undo resolves it passes every field assertion and is still invisible.
the_batch_removes_the_path_it_confirmed_not_whatever_the_id_now_names:green before and after, as a net. [Task]: the TUI delete skips the undo journal and the remove hooks #521 makes the worker re-resolve its target
to name the branch in the journal entry, and deriving the expected path from
that fresh resolution would compare live state with itself, quietly undoing
the
remove_verifiedguard from PR ✨ feat(tui,cli): multi-row selection and bulk delete (#484) #520. That is whyexpected_pathis aseparate argument rather than
found.path.a_failing_post_remove_hook_is_not_reported_as_a_failed_removal: the TUItwin of the CLI test from the ✨ feat(tui,cli): multi-row selection and bulk delete (#484) #520 review.
a_warning_rides_the_status_line_without_becoming_a_failureintui_app_tests.rs.Two deviations from the acceptance criteria, both deliberate:
CLI binary". The journal claims cannot hold to that: they rewrite
GWM_HISTORY_FILE, and the [Bug]: the $HOME guard in config_tests stops at expand_placeholders, and the other test binaries have the same shape #507 env guard derives who must serialise from atransitive walk of
src/, so aset_varintui_app_tests.rswould demandthe lock from every delete test in that binary. The contract tests therefore
live in their own binary (still driving the App), and the round trip mixes in
the real binary because nothing else can answer that question.
delete_branch = truethrough the TUI. The flag reachesthe same shared sequence the CLI's
remove_with_delete_branch_drops_branchalready covers, so it is covered by construction rather than by a
near-duplicate test.
Screenshots / TUI captures
None: nothing changes on screen except the status line, which is asserted in
a_warning_rides_the_status_line_without_becoming_a_failure.Checklist
<type>/#<issue>-<description>## [Unreleased]examples/gwm.toml.exampleupdated if config schema changed: no schema changeunwrap()on user-facing pathsprintln!in TUI render code, which is whyrun_phase_quietexists:run_phaseprinted its report on an aborting hook, and a worker threadwould have painted that over the alternate screen. Hook stdout and stderr
were already captured by
command_log::run_logged, so they reach theCommand Logs modal (
3) instead.Review findings left open
The Codex review loop stopped on convergence, not at zero: the last measured
P0P1P2_COUNTwas 4. One of those four was fixed after that measurement andthe count was not taken again, so here is the disposition of all four, by name.
e8ab738, witha_detached_head_is_recorded_as_no_branchcovering it.Close the remaining TOCTOU windows in the removal sequence #531. Narrowed here (the two loads are adjacent, with
worktree::listnolonger between them); closing it means
trust::evaluateaccepting bytes thecaller already read, which changes the trust surface shared by
create,bootstrapandreview.follow-up Close the remaining TOCTOU windows in the removal sequence #531. Needs a concurrent writer between two adjacent
statements; the entry that results fails
gwm undovisibly rather thansilently, and the obvious remedy adds a journal write on a failure path.
Same window shape, and only with
--delete-branch; closing it meansworktree::removetaking the branch as an argument, which changes itssignature for every caller.
The three deferred ones are one family (repeated observations of the same
state across the sequence) and #531 treats them together, since fixing them
piecemeal is what re-opened a window inside this very PR. None is reachable
without a concurrent writer hitting a gap between two adjacent statements,
which is why none blocks this merge. No test covers them: nobody writes a test
for a finding they chose not to fix, so the green checks say nothing about
them.
Linked issues / docs
Notes for reviewers
The trust gate is the piece worth a second opinion: it is the only place this
PR makes
ddo less than it did.RepoBatch::opentakes the App'sglobal_pathrather than re-resolvingglobal_config_path(), otherwise thehooks a delete runs would come from a different layering than the config panel
shows, and the tests that inject
None(#194) would have read the runner'sreal
~/.config/gwm/config.tomlwhile CI, which setsGWM_NO_GLOBAL_CONFIG=1workflow-wide, read nothing.