Skip to content

feat: add daft repo remove and daft repo subcommand category#448

Merged
avihut merged 57 commits intomasterfrom
daft-421/feat/remove-repo
May 2, 2026
Merged

feat: add daft repo remove and daft repo subcommand category#448
avihut merged 57 commits intomasterfrom
daft-421/feat/remove-repo

Conversation

@avihut
Copy link
Copy Markdown
Owner

@avihut avihut commented May 1, 2026

Summary

  • Adds daft repo remove [<path>] — removes a Git repo (bare git dir + every checked-out worktree), running worktree-pre-remove/worktree-post-remove hooks for each worktree before tearing it down. Works on any Git repo, daft-managed or vanilla; resolves the bare via git rev-parse --git-common-dir (CLI) or gix::discover (gitoxide backend) so it accepts a path to the project, a worktree, or a child directory.
  • Introduces a new daft repo subcommand category for repo-level operations (groundwork for the catalog work in Repo Catalog #357).
  • Updates the development sandbox: new mise-tasks/sandbox/clean-repos walks the sandbox test/ tree and tears down every git repo it finds via daft repo remove, so test repos with side effects (docker containers/volumes, mounts) are cleaned up properly rather than rm -rf'd.

Other notable bits delivered alongside:

  • Hook failure policy: best-effort per-worktree, warned-only runs exit 0, abort-mode failures bump exit code (matches daft prune).
  • Layout-safe parent walk: never removes the directory containing the project root, even when empty (regression fix from field testing).
  • Inline TUI for parallel multi-worktree removal; sequential output for single-task and empty-worktree cases (avoids the empty-table-with-headers visual glitch).
  • Pre-push cargo check --all-targets lefthook hook so post-rebase compile breakage gets caught before push.

Coverage

  • 1525 unit tests passing, including state-machine pinning of the full event flow, gix/CLI parity tests for both backends, parent-preservation guard, head-metadata population.
  • 11 bats integration tests in tests/integration/test_repo_remove.sh covering basic, dry-run, hooks, non-git rejection, run-from-various-paths, parent preservation (data-loss regression), bare-only repo, and the sandbox clean-repos helper.
  • 7 YAML manual scenarios in tests/manual/scenarios/repo/.
  • Man page, VitePress CLI reference page, help-text categorization, shell completions for bash/zsh/fish/Fig.

Test Plan

  • mise run test:unit passes
  • mise run clippy clean
  • mise run test:integration passes (bats + YAML)
  • mise run man:verify and mise run docs:cli:verify clean
  • Manual: daft repo remove <path> from outside the repo removes everything cleanly
  • Manual: daft repo remove (no arg) from inside a worktree triggers DAFT_CD_FILE redirect so the shell cds out before its cwd disappears
  • Manual: daft repo remove --dry-run prints the plan without touching anything
  • Manual: bare-only repo (cloned with --no-checkout) reports (bare): removed rather than rendering an empty TUI
  • Manual: a worktree-pre-remove hook that tears down a docker container fires for every worktree before removal
  • Manual: parent of project root is preserved even when it would otherwise be empty after removal

Fixes #421
Related: #443 (clone -n validation), #444 (daft list empty state), #446 (YAML fail_mode for pre-remove), #357 (catalog → jobs lookup for removed repos).

🤖 Generated with Claude Code

@avihut avihut added this to the Public Launch milestone May 1, 2026
@avihut avihut added the feat New feature label May 1, 2026
@avihut avihut self-assigned this May 1, 2026
@avihut
Copy link
Copy Markdown
Owner Author

avihut commented May 1, 2026

/claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 1, 2026

Claude finished @avihut's task in 3m 26s —— View job


Code Review — daft repo remove + daft repo subcommand category

This is a well-structured, deeply tested PR. The core logic is sound, the state-machine design is coherent, and the regression tests are thorough. The following are the actionable findings, roughly ordered by severity.


Findings

1. run() argv slicing is fragile — medium severity

src/commands/repo/remove.rs:48-51

let argv: Vec<String> = std::iter::once("git-daft-repo-remove".to_string())
    .chain(std::env::args().skip(3))
    .collect();

skip(3) hardcodes the assumption that the user typed daft repo remove …. If invoked via a future shortcut alias (e.g. daft rr … — one fewer word in argv[0]), or via git daft repo remove (one extra word), the skip offset is wrong and arguments will be silently dropped or misinterpreted. Every other multi-word subcommand in the codebase that pattern-matches on args[1]/args[2] re-slices at parse time via clap, but repo remove bypasses that by rebuilding argv manually.

The setup shortcuts path uses the same trick, so this is an accepted pattern for now — but it should at least assert the invariant (debug_assert_eq!(args[1], "repo"); debug_assert_eq!(args[2], "remove");) or extract a shared helper. Fix this →


2. Hardcoded "origin" remote in hook context — medium severity

src/commands/sync_shared.rs:459

let ctx = HookContext::new(
    hook_type,
    "repo-remove",
    &target.project_root,
    &target.bare_git_dir,
    "origin",        // ← hardcoded
    &target.project_root,
    &entry.path,)

Users who have configured remote = "upstream" in their daft config will see $DAFT_REMOTE_NAME=origin in their pre/post-remove hooks. Since execute_remove_worktree_task accepts only HooksConfig (not DaftSettings), the remote name isn't threaded through. The HooksConfig::default() construction at both the sequential and TUI call sites also drops the remote. A hook that uses $DAFT_REMOTE_NAME (e.g. to prune a remote tracking branch) will silently target the wrong remote. Fix this →


3. source_worktree in HookContext is set to project_root, not the main worktree — minor

src/commands/sync_shared.rs:457

let ctx = HookContext::new(&target.project_root,  // source_worktree
    &entry.path,           // worktree_path)

For a daft-managed bare-layout repo, project_root is the directory that contains .git/ and worktree siblings (e.g. /sandbox/test/myrepo). The "source worktree" (main worktree) lives at e.g. /sandbox/test/myrepo/main. Hooks that cd "$DAFT_SOURCE_WORKTREE" and run git commands will land in a non-git working directory and fail with a confusing error. This should be set to the main worktree path, which is the first entry in enumerate_worktrees or can be derived from git rev-parse --show-toplevel off the bare.


4. hook_viewport_budget — unchecked usize → u16 cast — low severity

src/commands/repo/remove.rs:323-325

fn hook_viewport_budget(worktrees_len: usize) -> u16 {
    5 + (worktrees_len as u16) * 4
}

Panics in debug mode when worktrees_len > 16383 (overflow in as u16 * 4). In release mode it silently wraps. Saturating arithmetic is the right fix:

fn hook_viewport_budget(worktrees_len: usize) -> u16 {
    let rows = worktrees_len.saturating_mul(4).saturating_add(5);
    rows.min(u16::MAX as usize) as u16
}

Fix this →


5. post-remove hook receives an already-deleted worktree path — worth documenting

src/commands/sync_shared.rs:400-419

run_remove_hook_best_effort(, HookType::PreRemove,);
remove_repo::remove_worktree_filesystem(target, &entry.path);
// entry.path is now gone ↓
run_remove_hook_best_effort(, HookType::PostRemove,);

By design, but the $DAFT_WORKTREE_PATH env var passed to the post-remove hook points to a directory that no longer exists. Hooks that cd "$DAFT_WORKTREE_PATH" or stat that path will fail. This matches the lifecycle semantics used by daft prune's post-remove, but since repo-remove is new it should be explicitly called out in the long_about text and the VitePress CLI reference page so hook authors know not to rely on the directory being present. Fix this →


6. HookExecutor::new constructs (and loads the trust DB) twice per worktree — performance note

src/commands/sync_shared.rs:441-483

run_remove_hook_best_effort calls HookExecutor::new(hooks_config.clone()) independently for PreRemove and PostRemove. If HookExecutor::new loads the trust DB from disk each time, this is 2 × n_worktrees disk reads. For a repo with 20 worktrees that's 40 trust-DB reads. The same pattern exists in the prune path so this is consistent, but it would be worth coalescing at the execute_remove_worktree_task level if trust-DB load time becomes a bottleneck.


7. parse_worktree_list_porcelain — duplicated flush block — style

src/core/worktree/remove_repo.rs:261-295

The same "flush current entry, reset state variables" block appears identically on blank line AND on worktree prefix. This is correct and tested, but extracting it into an inline closure or a helper keeps the parser DRY and reduces the chance of the two copies diverging. Not blocking.


8. No test for non-interactive stdin rejection — minor gap

src/commands/repo/remove.rs:80-86

if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
    bail!("Refusing to run without --force in non-interactive mode");
}

There is no integration test for echo "" | daft repo remove <path> → non-zero exit. The error message is important enough that a bats one-liner would be worthwhile. Not blocking.


Strengths

  • Regression test for parent-directory preservation (test_repo_remove_preserves_empty_parent_directory, unit tests in remove_repo.rs) is excellent — exactly the kind of regression test CLAUDE.md requires for every bug fix.
  • Dual-backend parity (for_each_backend) — running every core test against both CLI and gitoxide backends is the right call.
  • check_tui_failures_strict + unit tests — the asymmetry between tolerant (prune) and strict (repo-remove) exit-code semantics is clearly modeled and tested.
  • DaftSettings::load_global() — the explicit explanation of why load() can't be used here is a good comment that prevents future regression.
  • lefthook.yml pre-push cargo check --all-targets — catches post-rebase compile breakage before tests run; a worthwhile addition.
  • TUI viewport budget always allocated — the comment about hooks doing critical teardown (docker, mounts) even at verbose=0 is correct and the implementation matches the intent.

Summary

No blockers, but items 1 (argv fragility), 2 (hardcoded "origin" in hook env), and 3 (source_worktree pointing to non-git dir) are worth addressing before merge since they affect hook correctness for real users. Items 4 and 5 are low-risk quality improvements. Items 6–8 are non-blocking suggestions.

@avihut avihut force-pushed the daft-421/feat/remove-repo branch 2 times, most recently from 5eb76b3 to 880502e Compare May 2, 2026 10:26
avihut and others added 23 commits May 2, 2026 13:30
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.
avihut and others added 13 commits May 2, 2026 13:30
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>
@avihut avihut force-pushed the daft-421/feat/remove-repo branch from 880502e to f138e62 Compare May 2, 2026 10:30
avihut and others added 2 commits May 2, 2026 13:34
…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>
@avihut
Copy link
Copy Markdown
Owner Author

avihut commented May 2, 2026

/claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Claude finished @avihut's task in 6m 31s —— View job


Code Review — daft repo remove + daft repo subcommand category

Tasks

  • Read CLAUDE.md guidelines
  • Read core implementation files
  • Read test files
  • Analyze correctness, idioms, test coverage, hot-path constraints, security, and performance
  • Post review feedback

This is a well-engineered, deeply-tested PR. The prior review's four actionable items are all correctly resolved. Findings below are ordered by severity.


What the Prior Review Flagged → Now Addressed

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_path function, debug assert, and saturating viewport budget are clean fixes.
  • post-remove hook lifecycle documented in both long_about and 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 existing prune command pattern so behavior is uniform across removal paths.
  • Shell wrapper integrationdaft repo added to the layout|repo case in BASH_ZSH_WRAPPER with proper DAFT_CD_FILE semantics; daftcommand daft repo remove doesn'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>
@avihut avihut merged commit 7cf6769 into master May 2, 2026
11 checks passed
@avihut avihut deleted the daft-421/feat/remove-repo branch May 2, 2026 11:15
avihut added a commit that referenced this pull request May 2, 2026
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
avihut added a commit that referenced this pull request May 2, 2026
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
avihut added a commit that referenced this pull request May 2, 2026
* 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.
avihut added a commit that referenced this pull request May 9, 2026
…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>
avihut added a commit that referenced this pull request May 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove repo

1 participant