feat: add daft repo remove and daft repo subcommand category#448
feat: add daft repo remove and daft repo subcommand category#448
daft repo remove and daft repo subcommand category#448Conversation
|
/claude review |
|
Claude finished @avihut's task in 3m 26s —— View job Code Review —
|
5eb76b3 to
880502e
Compare
Captures the brainstormed design for issue #421: a new top-level `daft repo remove [<path>]` command that removes a Git repo, runs worktree-pre/post-remove hooks for each checked-out worktree, and a sandbox `clean-repos` helper that scans for repos under the dev sandbox and removes each via the new command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bite-sized TDD task list executing the design in docs/superpowers/specs/2026-04-28-remove-repo-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new OperationPhase::RemoveRepo variant with label "Removing repository". Mirrors the Prune-like display semantics in the TUI state mapping (active label "removing" and final-status mapping aligned with Prune). Scaffolding for the upcoming `daft repo remove` command (#421).
Adds two new TaskId variants used by the upcoming `daft repo remove` DAG: `RemoveWorktree(PathBuf)` keyed by worktree path so multiple worktrees on the same branch (or with empty/identical branch names) remain distinct, and `RemoveBare` for the terminal step that removes the bare git directory. Adds unreachable!() arms for these variants in the sync and prune DagExecutor task closures — those DAGs never produce remove tasks. The real execution wiring will land in a later bundle (#421).
Builds a DAG for `daft repo remove`: each worktree becomes an independent `RemoveWorktree` task with no inter-task dependencies (parallel removal), and a terminal `RemoveBare` task depends on every worktree task. With no worktrees, `RemoveBare` is the sole task. Models the topology so the existing DagExecutor can run worktree removals in parallel and only delete the bare git dir once all worktrees have been removed (#421).
Introduce src/core/worktree/remove_repo.rs with RepoTarget identifying the bare git dir and project root, and resolve_repo() to derive a target from a user-supplied path or the current working directory via git rev-parse --git-common-dir. First piece of the pure-logic layer for the upcoming daft repo remove command.
Add enumerate_worktrees(&RepoTarget) that runs `git --git-dir <bare> worktree list --porcelain` and returns only checked-out worktrees, filtering out the bare entry. Re-exports WorktreeEntry from prune to share the shape across modules. Operates by --git-dir (not cwd) so it keeps working when the cwd is being removed.
Add remove_worktree_filesystem(&RepoTarget, &Path) that first attempts `git worktree remove --force` and, on failure or partial removal, falls back to rm -rf followed by `git worktree prune` to keep the bare repo's worktree state in sync. Returns a RemoveWorktreeOutcome enum so callers can record which path was taken.
…eanup Add remove_bare_directory(&RepoTarget) which rm -rf's the bare git dir, walks upward through now-empty parent directories (project root, intermediate `.worktrees` dirs in nested layouts) until it hits the first non-empty directory, and best-effort drops the trust DB entry for the bare path. Trust DB cleanup uses the existing TrustDatabase::reset_repo (returns ()), with load/save errors swallowed since the repo is already gone.
`TrustDatabase::reset_repo` previously returned `()` and callers in `worktree::remove_repo` and `worktree::clone` unconditionally called `db.save()` afterwards. For `remove_repo`, that meant every invocation re-wrote the user's `repos.json` even when no entry was removed — and because the unit tests in `remove_repo.rs` don't sandbox `DAFT_CONFIG_DIR`, running them touched the real user file (violating the "never use this repo for testing" / test-isolation guarantees). Change `reset_repo` to return `bool` (true if any entry was removed), gate the save in `remove_repo` on that return value, and intentionally ignore the value in `clone` (which always saves to record the new layout). Add a regression test (`reset_repo_returns_true_only_when_something_is_removed`) covering the no-op, trust-only, and layout-only branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `src/commands/repo/{mod.rs,remove.rs}` with clap Args for
`daft repo remove` (path, --force, --dry-run, --verbose) and a stub
`run()` that bails with 'not yet implemented'. The subcommand
dispatcher in `commands::repo::run()` reads argv to route between
`remove`, help, and unknown verbs (via `handle_unknown_subcommand`).
Adds `DAFT_REPO_SUBCOMMANDS` to `suggest.rs` so the unknown-verb
fallback can suggest the right alternative; the `DAFT_SUBCOMMANDS`
top-level entry and `main.rs` routing land in the next commit.
Bundle C, plan task 8.
Adds `repo` to the top-level `match args[1]` block in `src/main.rs` so `daft repo …` (and `git daft repo …`) dispatches to `commands::repo::run()`. Inserts `"repo"` into `DAFT_SUBCOMMANDS` in alphabetical order so unknown-subcommand suggestions stay correct, and adds a sort assertion for `DAFT_REPO_SUBCOMMANDS`. Smoke-tested: daft repo -> prints help daft repo nope -> 'nope' is not a daft repo command (exit 1) daft repo remove -> 'not yet implemented' (exit 1) daft repo remove --help -> clap help text Bundle C, plan task 9.
Wire `daft repo remove --dry-run` to enumerate the resolved repo's worktrees and print a human-readable removal plan without touching the filesystem. Refactors `run()` to call a testable inner `run_with_args()` helper used by an end-to-end unit test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `execute_remove_worktree_task` and `execute_remove_bare_task` to sync_shared so both the upcoming sequential and TUI execution paths in `daft repo remove` can share task execution. Hook lifecycle events for the per-worktree pre/post-remove hooks are routed through TuiPresenter via an mpsc channel — the same machinery sync/prune already use. Hook failures never abort removal: errors from FailMode::Abort surface as a synthetic HookCompleted event so the renderer can summarize them. Helpers carry `#[allow(dead_code)]` until the next commit wires the `daft repo remove` sequential path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire `daft repo remove` to a sequential execution path used when -vv is passed or stderr is not a TTY. Without --force, prompts for [y/N] confirmation; in non-interactive mode, refuses to run without --force. The sequential path drives `execute_remove_worktree_task` for each worktree, then `execute_remove_bare_task`, prints a per-worktree status line, and surfaces failed/warned hook events in a trailing "Hooks:" summary block. Returns an error if any task or bare removal failed. `run_tui()` is a temporary alias for `run_sequential` — Bundle E replaces it with the OperationTable-driven path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the run_tui stub with a real OperationTable + DagExecutor implementation mirroring prune::run_tui. Each worktree gets its own RemoveWorktree task running in parallel; a terminal RemoveBare task depends on all of them and removes the bare git directory once every worktree is gone. Hooks emit live HookStarted/HookCompleted events through the channel for the renderer to surface, and the post-TUI hook summary printer mirrors prune's output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…repo When the user invokes daft repo remove from inside the repo being removed, write a safe path (the project_root parent, with fallbacks to dirs::data_dir / dirs::home_dir / "/") to DAFT_CD_FILE so the shell wrapper cd's the user out of the now-deleted directory after the command exits. Without DAFT_CD_FILE, prints the cd hint to stderr mirroring prune's behavior. Defer the cwd-redirect unit test to Bundle G's remove-from-inside.yml YAML scenario: unit tests share process-wide cwd / env state and run in parallel, which makes a unit test that mutates set_current_dir + DAFT_CD_FILE inherently racy with the rest of the suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pture WorktreeEntry has only Clone-able fields (PathBuf, Option<String>, bool, bool), but lacked the derive — forcing the repo-remove TUI path to hand-rebuild each entry field-by-field when capturing them into an Arc. Add #[derive(Clone, Debug)] and replace the 8-line manual reconstruction with `worktrees.to_vec()`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TUI executor closure used `.expect("entry for task path must exist")`
to look up the WorktreeEntry for a RemoveWorktree task. The invariant is
held by construction, but a panic inside a DagExecutor task hangs the
worker pool — the cvar wait never completes — so we should degrade
gracefully instead.
Replace with a let-else that emits a TaskMessage::Failed. The renderer
surfaces it normally and the rest of the DAG continues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`run_with_args` previously short-circuited via `?` when the executor returned an error, skipping `maybe_redirect_cwd`. But the cwd-redirect case is exactly partial-failure territory — a worktree containing the user's cwd may have been removed before another task failed, leaving the shell sitting in a deleted directory. Restructure to capture the executor result, always call `maybe_redirect_cwd`, then return the captured result. Behavior on success is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CLI documents `-v` as "hook details", but `run_tui` hardcoded `verbosity: 0` in `TableConfig` and reserved an unconditional 4-rows-per- worktree extra slack. Result: with `-v`, the TUI showed no hook detail rows (hidden at verbosity 0), and at `-vv` we already fall through to the sequential path so the slack was always paying for nothing. Thread `args.verbose: u8` through `run_tui`, drive `TableConfig.verbosity` from it (matching prune.rs), and gate the per-worktree extra-rows budget on `verbose >= 1`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "manage repositories" category to both the daft and git-daft help outputs, surfacing the new repo remove command.
The TUI rendered an empty-table-with-headers when `daft repo remove` ran
on a bare-only repo (no checked-out worktrees), looking like a glitch:
✓ Removing repository
Status Branch Path Base Changes Remote Age Owner Hash Commit
The TUI is meaningful only when there are concurrent worktree-removal
tasks to track. When the worktree list is empty the only task is the
single bare-removal, so route through `run_sequential` regardless of TTY
or verbosity. Output becomes a clear `(bare): removed` line.
Also polishes the confirmation prompt to handle the empty (and the
single-worktree) cases without the awkward "0 worktrees and the bare git
dir" / "1 worktrees" phrasing.
Adds a bats integration test that clones with `--no-checkout`, runs
`daft repo remove --force`, and asserts:
- the TUI Status header is NOT in the output
- `(bare): removed` IS in the output
- the project_root is gone
- the parent directory is preserved (data-loss regression guard)
Related daft list empty-state issue tracked separately at #444.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- maybe_redirect_cwd: drop the satisfied TODO. The YAML scenario `remove-from-inside.yml` already exercises the DAFT_CD_FILE path it pointed to. - run_sequential drain block: rewrite the TODO to reference #446 (the YAML-config gap that genuinely blocks abort-mode integration coverage), so future readers know the path is intentionally untested and where to track unblocking it. No behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two interrelated fixes to the daft repo remove implementation: CI fix — DAFT_CD_FILE was empty when the user ran from inside the removed worktree. `maybe_redirect_cwd` calls `std::env::current_dir()`, which returns ENOENT once the cwd's inode is gone. Snapshot the cwd before kicking off `run_sequential`/`run_tui` and pass it through. Caught by the `remove-from-inside.yml` YAML scenario; bats coverage missed it because it only checked directory removal, not the cd-file side effect. Strengthened the YAML scenario to assert cd_target is non-empty (not just that the file is non-zero bytes). PR review hardening: - Thread `settings.remote` through `execute_remove_worktree_task` so $DAFT_REMOTE reflects the user's `daft.remote` config instead of a hardcoded "origin". A user with daft.remote=upstream now sees the right value in their pre/post-remove hooks. - Set $DAFT_SOURCE_WORKTREE to the first non-bare-non-detached worktree via a new `main_worktree_path` helper, instead of `project_root` (the bare-parent dir, where `git rev-parse --is-inside-work-tree` returns false). Hook scripts that `cd $DAFT_SOURCE_WORKTREE` now land in a real working tree. - Saturating arithmetic in `hook_viewport_budget` so a >16k-worktree count clamps to u16::MAX rather than panicking in debug or wrapping in release. - `debug_assert!` on argv shape in `run()` to catch any future entry path that dispatches without the `daft repo remove ...` prefix. - Long-about + docs note that worktree-post-remove fires after the worktree directory is deleted; hooks must inspect the worktree from worktree-pre-remove instead. - Updated docs to drop the misleading "walks upward removing empty parents" line — that's the data-loss bug we already fixed; the command now stops at project_root. Coverage: - `remove-with-hooks.yml` now captures \$DAFT_REMOTE and probes \$DAFT_SOURCE_WORKTREE with `git rev-parse --is-inside-work-tree`, so a future regression that re-introduces the hardcode or points at the wrong directory will fail in CI. - New unit tests for `hook_viewport_budget(usize::MAX)` saturation and for `main_worktree_path` (main-first, detached-skipped, bare-only). All 7 repo/remove-* YAML scenarios pass on both CLI and gitoxide backends. All 1527 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI gitoxide-bash regression: `daft repo remove <relative-path>` from a parent directory failed with `could not canonicalize <rel>/<rel>/.git` under DAFT_USE_GITOXIDE=1. gitoxide's `repo.common_dir()` returns a path relative to the *process* cwd, not relative to `start`, so `start.join(common_dir)` double-prefixed the directory. The CLI backend was unaffected because `git -C <start> rev-parse --git-common-dir` returns relative-to-start. Anchor `start` to an absolute path upfront. Both backends then produce unambiguous joins. Adds a parametrized unit test that switches into the parent dir and passes the relative repo name, asserting both backends resolve correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported field test: running \`daft repo remove .\` from inside the worktree being deleted printed the binary's fallback message (\`Run \\\`cd ...\\\`\`) instead of redirecting the shell — leaving the user sitting in a deleted directory. Cause: the bash/zsh and fish shell wrappers had a per-subcommand cd-redirect block for \`daft layout\` but not for \`daft repo\`, so \`daft repo remove\` fell through to the default \`command daft "$@"\` branch with no \$DAFT_CD_FILE set. Fix: extend the existing \`layout)\` cases to \`layout|repo)\` (bash/zsh) and \`case layout repo\` (fish). Both subcommands have the same shape — a daft subcommand (not a separate binary) that needs cd integration — so the same wrapper block applies. Adds \`test_daft_repo_wrapper_writes_cd_file\` which sources the wrapper, \`cd\`s into a worktree, runs \`daft repo remove --force\`, and asserts \`builtin pwd\` returns a path outside the deleted project_root. The test fails without the wrapper change (pwd would still be the deleted worktree path) and passes with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ommands The daft repo remove field test surfaced a recurring class of gap: commands that mutate the filesystem layout the user's cwd lives inside need both binary-side DAFT_CD_FILE writes AND a shell-wrapper case in src/commands/shell_init.rs. The binary-only fix passes the YAML scenarios but leaves the user's interactive shell stuck in a deleted directory, because YAML tests bypass the wrapper. Document the contract in CLAUDE.md so future commands (and future agents) treat "binary writes DAFT_CD_FILE + wrapper has a case + bats test that sources the wrapper" as a single unit of work, not three optional steps. Also added a one-liner pointer in the "Adding a New Command" checklist so a developer adding such a command is reminded without having to re-read the Architecture section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two gaps in the documentation rollout for `daft repo remove`: - SKILL.md (the agent-facing reference) had no entry for `repo remove` in the Management table, and no caveat about the agent's cwd being invalidated when removing the repo from inside it. Both added. - docs/guide/worktree-workflow.md had a "Cleaning Up" subsection for `daft prune` but no mention of how to remove a whole repository. Added a "Removing a Repository" subsection right after, with the same examples shape as the rest of the page and a link to the CLI reference. The CLI reference page (docs/cli/daft-repo-remove.md) and VitePress sidebar entry already existed; this fills in the contextual coverage the per-command page can't provide on its own. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`execute_yaml_hook_with_rc` called `compute_repo_id()`, which runs `git rev-parse --git-common-dir` against the process cwd. When `daft repo remove <path>` was invoked from a cwd outside any git repo, the rev-parse errored, the error propagated up through `try_yaml_hook`, the executor caught it (warning to a buffered Output the user never sees), and fell through to legacy `.daft/hooks/` script discovery — which finds nothing for `daft.yml`-only repos. Net effect: every hook silently skipped. The HookContext already carries `git_dir` for the target repo. Use it via `compute_repo_id_from_common_dir` so hook firing is independent of where the user happens to be standing in the filesystem. Updated `make_ctx()` in the yaml_executor tests to use a real `TempDir` for `git_dir`. Pre-fix the tests passed fake `/project/.git` paths and silently picked up the test runner's cwd repo via the cwd-based call. New regression scenario `repo/remove-with-hooks-cwd-outside.yml` exercises the path-arg-with-cwd-outside case end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sandbox tasks run via `mise run sandbox:<task>`. mise loads its env from
`mise.toml -> _.source = ["shared-env.sh"]` in a clean subprocess that
doesn't inherit the parent shell's env, so `shared-env.sh` always exports
`DAFT_CONFIG_DIR=<git-common-dir>/.daft-sandbox` even when the calling
sandbox shell had it pointed elsewhere. Result: `daft repo remove`
invoked from inside a sandbox task operated on the *project's* trust DB
rather than the per-worktree sandbox's, leaving stale `repos.json`
entries and silently bypassing per-repo hook trust.
Add `sandbox_use_isolated_daft_env` in `mise-tasks/sandbox/_lib.sh` that
exports DAFT_CONFIG_DIR/DATA_DIR/STATE_DIR pointing into `$(sandbox_dir)`,
and call it from `clean-repos`. The runtime sandbox shell `.envrc`
generated by `sandbox/_default` already sets these three the same way;
this keeps mise-task daft invocations consistent.
Also add a defensive `[ -z "${DAFT_CONFIG_DIR:-}" ]` guard in
`shared-env.sh` for the direct-source case (a sandbox shell that
explicitly `source ./shared-env.sh`s — currently uncommon but the guard
costs nothing and documents intent).
Verified end-to-end: created two trusted hooked test repos in a
sandbox, ran `mise run sandbox:clean-repos`, observed worktree-pre-remove
hooks fire for all worktrees, sandbox `repos.json` reset to empty, and
no entries leaked into the project's `.daft-sandbox/repos.json`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-this, `mise run sandbox:clean` was a bare `rm -rf $sandbox_dir`. That bypasses `daft repo remove` entirely, so worktree-pre-remove and worktree-post-remove hooks never fire for any test repo in the sandbox. Trusted repos that wire hooks for external teardown — docker stop, DB drop, direnv revoke, background-job cancel — leak that state on every sandbox wipe. The `clean-sandbox` and `reset-sandbox` shell wrappers both call this task, so the leak hit every common cleanup path. Now runs clean-repos first, with failures tolerated. The `rm -rf` always runs at the end so a broken hook can never leave a sandbox behind. Add `--no-hooks` for the case where clean-repos itself is broken and the user just wants the sandbox gone. Verified end-to-end: sandbox with a 2.4 GB / 144k-file test setup (tax-analyzer bare + master + feature worktrees) → clean-repos runs the per-worktree hook firings via the path-arg fix from the prior commit, then rm -rf wipes the rest. Sandbox dir gone, no project repos.json leak. `--no-hooks` smoke-tested separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`sandbox:clean-repos` was tolerant of every failure: untrusted hook-defining repos got silently skipped (orphaning external state like docker containers, volumes, direnv allow entries), and `daft repo remove` failures were swallowed. Net effect: cleanup that always exits 0, even when hooks didn't run and resources leaked. Three behavior changes: 1. Pre-flight scan halts on untrusted hook-defining repos. Repos with worktree-pre-remove or worktree-post-remove in their daft.yml that aren't trusted (`trust=allow` in the sandbox repos.json) are listed with discoverable override hints. Path lookups canonicalize via realpath so trust DB keys stored as `/private/tmp/...` match find output formatted as `/tmp/...`. 2. `--trust-hooks` flag pre-grants trust to hook-defining untrusted repos via `daft hooks trust --force <path>`, then proceeds. Trust grants land in the per-worktree sandbox trust DB (which gets wiped at the end of `sandbox:clean`), so the security blast radius is exactly the duration of the cleanup. 3. Halt on `daft repo remove` failure (was: log "(failed — continuing)" and move on). `--force` restores the tolerant behavior for the case where you've decided you don't care about leaks. Iterate only over bare-parents (paths whose `.git` is a directory) — the worktree-pointer paths from the same find loop produced phantom "no such file" failures because `daft repo remove <bare>` already cascaded and removed them, and now those phantom failures would trip the halt. `sandbox:clean` wires `--trust-hooks`, `--force`, and `--no-hooks` through. With `--force` it tolerates clean-repos exit and rms anyway; without it, a clean-repos halt also halts `clean` and leaves the sandbox for triage. Verified end-to-end against six scenarios: T1 default → halt (untrusted hook) → sandbox preserved ✅ T2 --trust-hooks → grant + run + wipe → marker confirms hook ✅ T3 --force on warned-mode hook → wipes → sandbox gone ✅ T4 --no-hooks → skip clean-repos → no hook fired, wipes ✅ T5 real daft failure (chmod 000) → halt → sandbox preserved ✅ T6 --force on the same → wipes anyway → sandbox gone ✅ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt-out Sandbox cleanup is the steady state for daft development: re-clone, test, clean. Each cleanup wipes the sandbox's trust DB along with everything else, so a re-cloned test repo is always untrusted on the next cycle. Defaulting to halt-on-untrusted produced a halt-then-flag-then-rerun ritual on most cleanup attempts, with no real safety benefit — the sandbox is 100% user-placed by construction and the trust grant is wiped seconds later anyway. Trust-hook is now the default. --no-trust-hooks brings back the strict pre-flight halt for the rare case where you want to be sure nothing arbitrary runs. --trust-hooks accepted as a no-op for muscle memory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
880502e to
f138e62
Compare
…ation Builds N hook-defining git repos at random paths and depths under the per-worktree sandbox, then runs sandbox:clean and verifies that every planned (repo, branch) tuple's worktree-pre-remove hook fired exactly once and the sandbox is fully wiped. Fully seeded — pass --seed to reproduce a layout, or let it generate one and print it up front. --setup-only stops after populating so you can inspect the layout before triggering cleanup manually. Not part of the regular test suite. Exercises the dev-side cleanup machinery (mise-tasks/sandbox/*), not daft itself. Run when changing clean / clean-repos to confirm no regressions in the repo enumeration, auto-trust path, or hook firing. Verified locally: - 25 repos / 40 worktrees → PASS - 50 repos / 76 worktrees → PASS - 30 repos / 46 worktrees → PASS - --seed N reproduces identical layout across runs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Branch picked up v1.10.3 from master after rebase; regenerated man page matches the current version. Required by xtask-test's man-up-to-date check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/claude review |
|
Claude finished @avihut's task in 6m 31s —— View job Code Review —
|
| Prior Finding | Status |
|---|---|
1. skip(3) argv fragility |
✅ debug_assert! added (remove.rs:59-63) |
2. Hardcoded "origin" remote |
✅ remote_name: &str threaded through from settings.remote |
3. source_worktree → non-git dir |
✅ main_worktree_path() finds first non-bare, non-detached entry |
4. u16 overflow in hook_viewport_budget |
✅ Saturating arithmetic with .try_into().unwrap_or(u16::MAX) |
5. worktree-post-remove path-gone documentation |
✅ In long_about (remove.rs:20-22) and in the VitePress doc |
| 7. Duplicate flush block in parser | ⏸ Still present — non-blocking |
| 8. No test for stdin non-interactive rejection | ⏸ Still missing — non-blocking |
New Findings
1. HooksConfig::default() silently drops user-configured hook settings — minor correctness
src/commands/repo/remove.rs:217, src/commands/repo/remove.rs:435
Both the sequential and TUI paths construct HooksConfig::default() instead of loading from settings. This drops user-configured hook behavior: daft.hooks.timeout, daft.hooks.output.quiet, daft.hooks.output.tailLines, per-hook failMode, etc.
// sequential path
let hooks_config = crate::hooks::HooksConfig::default();
// TUI path
let hooks_arc = Arc::new(crate::hooks::HooksConfig::default());DaftSettings::load_global() is already called at line 81, but there is no corresponding load_hooks_config_global() call. Since prune.rs:145 has the same gap, this is a pre-existing pattern, not a regression introduced here. Still worth flagging because users with custom hook timeouts (e.g. daft.hooks.timeout = 600 for long-running docker teardown hooks) will silently time out at the 300-second default during repo remove. Consider calling crate::core::settings::load_hooks_config_global() and using the result here — same argument as load_global() for use_gitoxide. Fix this →
2. run_sequential double-counts failure from the same task — minor redundancy
src/commands/repo/remove.rs:234-244
let line = match &msg {
TaskMessage::Failed(e) => {
any_failed = true; // ← set here when msg is Failed
e.clone()
}
_ => "removed".to_string(),
};
if matches!(status, TaskStatus::Failed) {
any_failed = true; // ← set again; status == Failed iff msg == Failed(e)
}execute_remove_worktree_task only returns (TaskStatus::Failed, TaskMessage::Failed(...)) as a pair — they're always in sync. The if matches!(status, TaskStatus::Failed) check is unreachable without already having set any_failed in the match arm above. Not a correctness bug (double-setting a bool to true is harmless), but the redundancy could mask a future refactor where the two fall out of sync. Either the match arm or the if matches! check should be removed.
3. !success || warned condition adds successful warn-mode hooks to the summary — theoretical edge case
src/commands/repo/remove.rs:270
if !success || warned {
hook_summaries.push(HookSummary { ... });
}If warned = true while success = true (hook ran in FailMode::Warn and succeeded), this pushes to hook_summaries and renders it as "warned" in the output. The condition should likely be if !success (summarise failures only), with the sequential any_failed check already gated on !success && !warned. In practice, the hook execution stack likely never emits success=true, warned=true, but the invariant is implicit rather than enforced. Fix this →
4. repo/mod.rs — --version falls into unknown-subcommand handler — minor UX
src/commands/repo/mod.rs:20
daft repo --version hits handle_unknown_subcommand("daft repo", "--version", ...), which suggests "remove" as a typo correction. The --help/-h cases are handled but --version is not. Low-frequency but surprising. Suggest adding "--version" to the handled cases:
"" | "--help" | "-h" | "--version" | "-V" => {
print_help();
Ok(())
}5. enumerate_worktrees_gix silently skips stale linked worktrees — documentation gap
src/core/worktree/remove_repo.rs:145-148
let path = match proxy.base() {
Ok(p) => std::fs::canonicalize(&p).unwrap_or(p),
Err(_) => continue, // stale worktree silently skipped
};A linked worktree whose base path can't be resolved (broken symlink, manually rm -rf'd without git worktree prune) is silently skipped. The CLI path doesn't have this behavior — git worktree list --porcelain still emits the entry with its recorded path (even if it no longer exists on disk). This gix/CLI asymmetry means the gix backend reports fewer worktrees to remove than the CLI backend for repos with stale entries. The bare git dir's worktrees/ directory will still contain the stale admin entries after the bare is removed (though that's moot since the whole directory is rm -rf'd). Worth a comment explaining why the skip is safe: the bare dir removal via fs::remove_dir_all destroys any remaining admin entries regardless.
6. Sequential path rx channel not drained after the bare-removal task
src/commands/repo/remove.rs:285-294
After execute_remove_bare_task(target) there is no rx.try_recv() drain. execute_remove_bare_task doesn't run hooks so no events are emitted, making this safe today. But if a future refactor adds a post-bare-removal hook (e.g., a repo-level post-remove hook distinct from per-worktree hooks), the events would be silently dropped. A defensive drain at line 294 (or after execute_remove_bare_task) would future-proof the sequential path. Non-blocking.
Strengths
- All four actionable prior-review items resolved correctly. The remote name threading,
main_worktree_pathfunction, debug assert, and saturating viewport budget are clean fixes. post-removehook lifecycle documented in bothlong_aboutand the VitePress CLI reference page — exactly what hook authors need.- Dual-backend parity tests via
for_each_backend— every core assertion runs under both the CLI and gitoxide backends. - Parent-preservation regression test (
remove_bare_directory_does_not_delete_empty_parent_of_project_root) satisfies the CLAUDE.md requirement that every bug fix include a regression test. debug_assert!on argv shape — correctly surfaces future dispatcher mismatches in debug builds without overhead in release.HooksConfig::default()consistency — matches the existingprunecommand pattern so behavior is uniform across removal paths.- Shell wrapper integration —
daft repoadded to thelayout|repocase inBASH_ZSH_WRAPPERwith properDAFT_CD_FILEsemantics;daft→command daft repo removedoesn't lose cd support.
Summary
No blockers. Item 1 (hooks config from settings) is the most likely to bite real users (long docker-teardown hooks timing out at the 300s default). Items 2 and 3 are low-risk cleanup. Items 4–6 are minor or documentary. Safe to merge after the CLAUDE.md pre-merge checklist passes.
#1 (HooksConfig from settings): Load hooks_config via load_hooks_config_global() so user-configured hook timeouts (daft.hooks.timeout) are honored. Real user impact — long docker-teardown hooks were silently timing out at the 300s default. Codebase-wide propagation tracked in #456. #2 (double-counted failure): execute_remove_worktree_task always returns (TaskStatus::Failed, TaskMessage::Failed(_)) as a pair. Drop the redundant `if matches!(status, TaskStatus::Failed)` check; rely on the message arm alone via _status binding. #3 (warned-but-success in summary): Change `if !success || warned` to `if !success`. A warn-mode hook that succeeded shouldn't appear in the problem summary; the `warned` field exists to color *failed* hooks differently from abort-mode failures. #4 (--version unknown handler): `daft repo --version` and `-V` now print the version instead of triggering a typo-suggestion path. #5 (gix stale linked worktrees): Comment why the silent skip is safe — the bare's worktrees/ admin entry is destroyed by remove_dir_all anyway, so skipping a stale entry just avoids a redundant removal of a path that's already gone. #6 (post-bare event drain): Add a defensive `rx.try_recv()` loop after execute_remove_bare_task. No-op today (bare task fires no hooks), but future-proofs against a repo-level post-remove hook silently dropping events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every command that constructed a hook executor used `HooksConfig::default()`, which silently ignored every `daft.hooks.*` setting under git config — `enabled`, `timeout`, `userDirectory`, `output.quiet`, etc. — even though the loaders to read them already existed in `core/settings`. Replace each call site with `load_hooks_config[_global]()`, mirroring the prior `repo remove` fix from PR #448. Pre-clone sites in `clone.rs` use `_global` (cwd may not be inside a repo); all in-repo commands use `load_hooks_config()` to pick up repo-local overrides too. Hoist the loader call out of `create_satellite_worktrees`'s per-branch loop and out of the `--all-branches` orchestrator's two construction sites — load once, clone per-iteration — so multi-branch clones don't re-read global config N times. Regression test: `config-disabled-respected.yml` sets `daft.hooks.enabled=false` in repo-local config and asserts the post-create hook does NOT fire on a subsequent checkout. Verified to fail on master (marker file created because `enabled=true` default ignored config) and pass with this fix. Fixes #456
Every command that constructed a hook executor used `HooksConfig::default()`, which silently ignored every `daft.hooks.*` setting under git config — `enabled`, `timeout`, `userDirectory`, `output.quiet`, etc. — even though the loaders to read them already existed in `core/settings`. Replace each call site with `load_hooks_config[_global]()`, mirroring the prior `repo remove` fix from PR #448. Pre-clone sites in `clone.rs` use `_global` (cwd may not be inside a repo); all in-repo commands use `load_hooks_config()` to pick up repo-local overrides too. Hoist the loader call out of `create_satellite_worktrees`'s per-branch loop and out of the `--all-branches` orchestrator's two construction sites — load once, clone per-iteration — so multi-branch clones don't re-read global config N times. Regression test: `config-disabled-respected.yml` sets `daft.hooks.enabled=false` in repo-local config and asserts the post-create hook does NOT fire on a subsequent checkout. Verified to fail on master (marker file created because `enabled=true` default ignored config) and pass with this fix. Fixes #456
* fix(hooks): honor user git-config in commands that fire hooks Every command that constructed a hook executor used `HooksConfig::default()`, which silently ignored every `daft.hooks.*` setting under git config — `enabled`, `timeout`, `userDirectory`, `output.quiet`, etc. — even though the loaders to read them already existed in `core/settings`. Replace each call site with `load_hooks_config[_global]()`, mirroring the prior `repo remove` fix from PR #448. Pre-clone sites in `clone.rs` use `_global` (cwd may not be inside a repo); all in-repo commands use `load_hooks_config()` to pick up repo-local overrides too. Hoist the loader call out of `create_satellite_worktrees`'s per-branch loop and out of the `--all-branches` orchestrator's two construction sites — load once, clone per-iteration — so multi-branch clones don't re-read global config N times. Regression test: `config-disabled-respected.yml` sets `daft.hooks.enabled=false` in repo-local config and asserts the post-create hook does NOT fire on a subsequent checkout. Verified to fail on master (marker file created because `enabled=true` default ignored config) and pass with this fix. Fixes #456 * docs(clone): explain `_global` choice in post-hook helpers Address PR #461 review feedback: `run_post_clone_hook`, `run_post_create_hook`, and `create_satellite_worktrees` all use `load_hooks_config_global()` even though cwd may be inside the freshly- cloned repo. The local loader requires being inside a repo (it errors out via `gix_repo()?` when the user invoked `daft clone` from a non-repo directory), so `_global` is the cwd-tolerant choice. Repo-local overrides on a fresh clone are vanishingly rare — deliberate tradeoff. Document it in code so future readers don't read `_global` as an oversight.
…state/daft
`execute_yaml_hook_with_rc` always wrote invocation/job records under
`daft_state_dir().join("jobs").join(repo_hash)`. Even after #448 routed
the repo identity through `ctx.git_dir`, every yaml_executor test still
created a fresh repo UUID and a matching orphan directory in the user's
real `~/.local/state/daft/jobs/`. Each `cargo test` accumulated more.
Inject the state base instead. `LogStore::for_repo_in(repo_hash, base)`
parameterizes the write root; `for_repo` keeps the production behavior
by delegating with `daft_state_dir()`. `HookContext` gains an optional
`state_dir` (set via `with_state_dir`) that the executor consults
before falling back to the global. Tests set it to the same tempdir
they already use as `git_dir`, so all on-disk side effects stay
inside the tempdir.
Adds a regression test that runs the executor end-to-end from the
test fixture, then asserts (a) the LogStore base under the tempdir
exists and (b) no matching UUID dir leaked into `daft_state_dir()`.
Fixes #478
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…state/daft (#480) `execute_yaml_hook_with_rc` always wrote invocation/job records under `daft_state_dir().join("jobs").join(repo_hash)`. Even after #448 routed the repo identity through `ctx.git_dir`, every yaml_executor test still created a fresh repo UUID and a matching orphan directory in the user's real `~/.local/state/daft/jobs/`. Each `cargo test` accumulated more. Inject the state base instead. `LogStore::for_repo_in(repo_hash, base)` parameterizes the write root; `for_repo` keeps the production behavior by delegating with `daft_state_dir()`. `HookContext` gains an optional `state_dir` (set via `with_state_dir`) that the executor consults before falling back to the global. Tests set it to the same tempdir they already use as `git_dir`, so all on-disk side effects stay inside the tempdir. Adds a regression test that runs the executor end-to-end from the test fixture, then asserts (a) the LogStore base under the tempdir exists and (b) no matching UUID dir leaked into `daft_state_dir()`. Fixes #478 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
daft repo remove [<path>]— removes a Git repo (bare git dir + every checked-out worktree), runningworktree-pre-remove/worktree-post-removehooks for each worktree before tearing it down. Works on any Git repo, daft-managed or vanilla; resolves the bare viagit rev-parse --git-common-dir(CLI) orgix::discover(gitoxide backend) so it accepts a path to the project, a worktree, or a child directory.daft reposubcommand category for repo-level operations (groundwork for the catalog work in Repo Catalog #357).mise-tasks/sandbox/clean-reposwalks the sandboxtest/tree and tears down every git repo it finds viadaft repo remove, so test repos with side effects (docker containers/volumes, mounts) are cleaned up properly rather thanrm -rf'd.Other notable bits delivered alongside:
daft prune).cargo check --all-targetslefthook hook so post-rebase compile breakage gets caught before push.Coverage
tests/integration/test_repo_remove.shcovering basic, dry-run, hooks, non-git rejection, run-from-various-paths, parent preservation (data-loss regression), bare-only repo, and the sandboxclean-reposhelper.tests/manual/scenarios/repo/.Test Plan
mise run test:unitpassesmise run clippycleanmise run test:integrationpasses (bats + YAML)mise run man:verifyandmise run docs:cli:verifycleandaft repo remove <path>from outside the repo removes everything cleanlydaft repo remove(no arg) from inside a worktree triggersDAFT_CD_FILEredirect so the shellcds out before its cwd disappearsdaft repo remove --dry-runprints the plan without touching anything--no-checkout) reports(bare): removedrather than rendering an empty TUIworktree-pre-removehook that tears down a docker container fires for every worktree before removalFixes #421
Related: #443 (clone
-nvalidation), #444 (daft listempty state), #446 (YAMLfail_modefor pre-remove), #357 (catalog → jobs lookup for removed repos).🤖 Generated with Claude Code