From f8a4d47b9065bc2c6392d2fe14bd58194a04dd4e Mon Sep 17 00:00:00 2001 From: nettee Date: Tue, 7 Jul 2026 16:47:47 +0800 Subject: [PATCH 1/3] spec --- .../design.md | 152 ++++++++++++++++++ .../spec.md | 102 ++++++++++++ .../steps.md | 5 + 3 files changed, 259 insertions(+) create mode 100644 specs/change/20260707-interactive-worktree-delete/design.md create mode 100644 specs/change/20260707-interactive-worktree-delete/spec.md create mode 100644 specs/change/20260707-interactive-worktree-delete/steps.md diff --git a/specs/change/20260707-interactive-worktree-delete/design.md b/specs/change/20260707-interactive-worktree-delete/design.md new file mode 100644 index 0000000..f0bdd42 --- /dev/null +++ b/specs/change/20260707-interactive-worktree-delete/design.md @@ -0,0 +1,152 @@ +# Design + +## Research + +### Existing System + +- The CLI is hand-written in `src/cli.rs`: top-level commands include `list` and `remove`, but there is no existing `delete` command. Source: `src/cli.rs:14-29`. +- Argument dispatch routes `list` to `parse_list` and `remove` to `parse_remove`; unknown commands fail through usage handling. Source: `src/cli.rs:278-305`. +- `wtk remove` accepts at most one optional path plus `--delete-branch` and `--no-clipboard`; no-arg `remove` is currently valid. Source: `src/cli.rs:526-550`. +- `wtk list` currently supports only `--json`; non-JSON output is a table. Source: `src/cli.rs:655-675`, `src/list.rs:74-89`. +- Repository discovery uses `git worktree list --porcelain`; the first parsed worktree becomes the main Repository Worktree, and `RepoContext` records current/main roots and all worktrees. Source: `src/gitexec.rs:225-260`. +- `wtk list` builds one `ListRow` per repository worktree, then sorts and renders those rows; it reads coordinated state and marks coordinated Primary rows as `primary_worktree` with an `auxiliary_refs` summary. Source: `src/worktree.rs:441-480`. +- The list model is flat: `ListOutput` has `worktrees: Vec`, and table rendering iterates rows directly with no grouped/collapsed sections. Source: `src/list.rs:16-41`, `src/list.rs:371-430`. +- List rows already include display name, absolute path, branch, current/main flags, dirty status, labels, diagnostics, updated time, short head, and optional Auxiliary Ref details. Source: `src/list.rs:22-41`. +- Dirty, main, current, locked, prunable, detached, and bare states are represented as labels; dirty state is computed with `git status --porcelain=v1 --untracked-files=all`, with generated auxiliary refs ignored for coordinated Primary rows. Source: `src/list.rs:119-150`, `src/list.rs:181-208`, `src/list.rs:326-342`, `src/worktree.rs:449-467`. +- `wtk list` sorting is by newest HEAD commit timestamp, then current, main, and display name. Source: `src/list.rs:91-100`. +- `wtk list` table columns are marker, worktree display name, branch text, updated, state, and short head; docs say absolute paths are omitted from default output and included in JSON. Source: `src/list.rs:371-430`, `docs/user-guide.md:68-82`. +- Docs describe `wtk list` as showing ordinary and coordinated Primary Repository worktrees together, with Auxiliary Ref health summaries such as `refs 2/2 ok`; this matches the flat table model with inline coordinated summary. Source: `docs/user-guide.md:151-162`. + +### Existing Removal Behavior + +- `wtk remove` with no path removes the current linked worktree, but fails from the main worktree because a path is required there. Source: `src/worktree.rs:747-758`. +- `wtk remove` rejects targets that are not linked worktrees and rejects the main worktree path. Source: `src/worktree.rs:760-771`. +- For a coordinated Primary Repository worktree, `wtk remove` validates branch/ref state, requires the Primary and all Auxiliary Repository worktrees to be clean, removes each Auxiliary Repository worktree, force-removes the Primary Repository worktree, then removes coordinated state. Source: `src/worktree.rs:522-595`. +- Removing from an Auxiliary Repository side worktree is rejected with `remove is not supported for worktrees with auxiliary state`. Source: `src/worktree.rs:784-788`, `e2e/test_auxiliary_group.py:499-523`. +- Plain linked worktree removal requires the worktree to be clean, runs `git worktree remove`, and optionally deletes the local branch with `git branch -d`. Source: `src/worktree.rs:789-827`. +- Coordinated removal preflights branch deletion and locked worktrees; e2e tests assert failures leave the Primary and Auxiliary Repository worktrees and state intact. Source: `e2e/test_auxiliary_group.py:693-731`, `e2e/test_auxiliary_group.py:734-771`. +- Existing docs say `wtk remove` removes a coordinated set and `wtk remove --delete-branch` removes primary and auxiliary worktrees and branches after preflight checks. Source: `docs/user-guide.md:162`. + +### Design Inputs + +- No prompt/TUI library is currently declared; dependencies are limited to serialization/hash/TOML/globset crates. Source: `Cargo.toml:6-12`. +- Style/color is already gated on stdout being a terminal and `NO_COLOR` not being set. Source: `src/cli.rs:273-276`. +- Current list output has a stable JSON surface for scripts; docs explicitly recommend `wtk list --json` for scripts. Source: `docs/user-guide.md:76-82`. +- Auxiliary Group definitions are config-level objects; existing worktree coordinated state is stored separately in `.wtk/worktrees.json` keyed by absolute Primary Repository worktree path, and changing config later does not mutate existing worktrees. Source: `docs/user-guide.md:151-160`, `src/auxiliary.rs:333-389`. +- Auxiliary Group listing is implemented separately from Repository Worktree listing; `wtk list` shows worktree state, not configured groups. Source: `src/auxiliary.rs:263-313`, `src/worktree.rs:441-480`. + +### Constraints & Dependencies + +- Adding `wtk delete` requires adding a new top-level command and parser/dispatcher path because it does not exist today. Source: `src/cli.rs:14-29`, `src/cli.rs:278-305`. +- Because no interactive dependency exists today, an interactive selector either needs a new dependency or a small in-repo terminal interaction implementation. Source: `Cargo.toml:6-12`. +- `wtk remove` currently refuses dirty worktrees, including coordinated members; allowing dirty deletion in the new interactive flow cannot simply call existing `remove` unchanged. Source: `src/worktree.rs:545-549`, `src/worktree.rs:789-790`. +- `wtk list` is currently flat, not folded/grouped; changing default grouping would alter the documented scanning-oriented table behavior. Source: `src/list.rs:16-41`, `src/list.rs:371-430`, `docs/user-guide.md:74`. +- Existing e2e coverage for list/remove/coordinated workflows lives in black-box tests under `e2e/`, especially repository mode and auxiliary group tests. Source: `e2e/test_auxiliary_group.py:469-771`. + +### Key References + +- CLI parse/dispatch: `src/cli.rs:14-29`, `src/cli.rs:278-305`, `src/cli.rs:526-675`. +- Worktree list/remove core: `src/worktree.rs:441-480`, `src/worktree.rs:522-595`, `src/worktree.rs:747-835`. +- List model/rendering: `src/list.rs:16-41`, `src/list.rs:91-150`, `src/list.rs:181-208`, `src/list.rs:326-430`. +- Git worktree discovery: `src/gitexec.rs:225-309`. +- Auxiliary state/config helpers: `src/auxiliary.rs:263-313`, `src/auxiliary.rs:333-460`, `src/auxiliary.rs:499-535`. +- User docs: `docs/user-guide.md:68-82`, `docs/user-guide.md:112-162`. +- Coordinated e2e tests: `e2e/test_auxiliary_group.py:469-771`. + +## Design Detail + +### Design Decisions + +- Add a new top-level `wtk delete` command rather than changing `wtk remove`; `delete` owns the interactive batch workflow while existing `remove` keeps its current path/current-worktree semantics. Source: `src/cli.rs:14-29`, `src/cli.rs:526-550`, `src/worktree.rs:747-758`. +- `wtk delete` with no arguments is interactive-only and must fail fast outside a terminal instead of waiting for input; the project already uses terminal detection for output style, and list JSON remains the script-facing interface. Source: `src/cli.rs:273-276`, `docs/user-guide.md:76-82`. +- Do not change default `wtk list` output in this Spec. The current list contract is a flat scanning table and JSON `worktrees` array; `wtk delete` should use a similar flat row presentation rather than forcing list grouping. Source: `src/list.rs:16-41`, `src/list.rs:371-430`, `docs/user-guide.md:74`. +- Build `wtk delete` candidates from the same repository/list facts as `wtk list`: display name, branch/ref text, updated time, state labels, short head, dirty/current/main flags, diagnostics, and Auxiliary Ref summary. Source: `src/worktree.rs:441-480`, `src/list.rs:22-41`, `src/list.rs:371-430`. +- Exclude protected roots from selection: the main Repository Worktree is not deletable, and the current Repository Worktree is not selected/deleted by this batch command. Source: `src/worktree.rs:747-771`, `src/list.rs:119-150`. +- Selecting a coordinated Primary Repository worktree deletes its recorded coordinated set: the Primary Repository Worktree and all recorded Auxiliary Repository Worktrees. This matches existing `wtk remove` coordinated semantics while keeping `wtk delete` selection flat. Source: `src/worktree.rs:522-595`, `docs/user-guide.md:162`. +- Do not support selecting Auxiliary-side worktrees directly; existing removal rejects auxiliary-side worktrees, and coordinated deletion should be initiated from the recorded Primary Repository worktree row. Source: `src/worktree.rs:784-788`, `e2e/test_auxiliary_group.py:499-523`. +- Allow dirty selected worktrees in `wtk delete` after exact `Y` confirmation; this requires a new force-removal path because current `wtk remove` rejects dirty standalone and coordinated members. Source: `src/worktree.rs:545-549`, `src/worktree.rs:789-790`. +- Keep structural safety checks: locked worktrees, broken refs, and branch/ref drift should fail visibly rather than be hidden by force deletion. Existing coordinated tests already assert locked and preflight failures preserve worktrees/state. Source: `src/worktree.rs:531-556`, `e2e/test_auxiliary_group.py:576-689`, `e2e/test_auxiliary_group.py:693-771`. +- `wtk delete` never deletes branches. Existing branch deletion is an explicit `--delete-branch` behavior on `wtk remove`; the interactive delete command should remove worktrees only. Source: `src/worktree.rs:550-585`, `src/worktree.rs:801-827`, `docs/user-guide.md:162`. +- Use `dialoguer` for the multi-select interaction because it provides Space/Enter multi-select and cancelable `interact_opt()` while fitting a small hand-written CLI. Source: `Cargo.toml:6-12`, `https://docs.rs/dialoguer/latest/dialoguer/struct.MultiSelect.html`, `https://crates.io/crates/dialoguer`. +- Implement the final destructive confirmation in WTK as exact line input requiring uppercase `Y`, not `dialoguer::Confirm`, because `Confirm` is a y/n prompt rather than literal `Y`-only confirmation. Source: `https://docs.rs/dialoguer/latest/dialoguer/struct.Confirm.html`. + +### System Structure + +```mermaid +flowchart TD + A[wtk delete] --> B{TTY?} + B -- no --> C[fail: interactive terminal required] + B -- yes --> D[resolve repository + read coordinated state] + D --> E[build flat delete candidates from list-style rows] + E --> F[dialoguer MultiSelect] + F --> G{selection?} + G -- cancel/empty --> H[cancel without deletion] + G -- selected --> I[print full summary incl. coordinated members + dirty markers] + I --> J{input exactly Y?} + J -- no --> H + J -- yes --> K[delete selected candidates] + K --> L[print success/failure summary] + L --> M{any failure?} + M -- yes --> N[exit non-zero] + M -- no --> O[exit zero] +``` + +### System Procedure + +1. Parse `wtk delete` with no positionals or flags except help. +2. Require interactive terminal for stdin/stdout before rendering the selector. +3. Resolve the Primary Repository context and coordinated state. +4. Build flat candidates in the same order and with the same core fields as `wtk list`. +5. Mark/omit non-selectable rows: main, current, auxiliary-side state, and rows with structural diagnostics that cannot be safely removed. +6. Run a multi-select prompt with Space toggling and Enter submitting. +7. If canceled or empty, report cancellation and exit successfully without deletion. +8. Print a full summary with absolute paths, branch/ref text, dirty markers, and coordinated members that will be removed. +9. Read one confirmation line; only exact `Y` proceeds. +10. Delete selected candidates one by one. Coordinated candidates delete each Auxiliary Repository Worktree then the Primary Repository Worktree and update state. +11. Print successful and failed deletion summaries. Any failure returns non-zero. + +### Interfaces / APIs + +- CLI: + - `wtk delete`: interactive batch delete. + - `wtk delete --help`: usage text. +- No `wtk list` CLI change in this Spec. +- Internal likely seams: + - reusable list/candidate builder over `RepoContext` + auxiliary state; + - force worktree removal helper that does not delete branches; + - interactive delete runner separated enough to e2e-test through a pseudo-terminal. + +### Change Scope + +Impact Areas: +- CLI command parsing and help text: add `delete` without changing `remove` semantics. +- Worktree operations: add interactive batch delete, force deletion for confirmed dirty worktrees, coordinated set handling, summary/error reporting. +- List/candidate modeling: reuse or adapt list rows while preserving `wtk list` output. +- Dependencies: add `dialoguer` for human TTY multi-select. +- Tests/docs: add e2e coverage for interactive delete and update user-facing docs. + +Planned File Changes: +- `Cargo.toml` / lockfile: add `dialoguer` dependency. +- `src/cli.rs`: parse/dispatch/help for `wtk delete`. +- `src/worktree.rs`: interactive delete command orchestration and deletion execution helpers, likely sharing coordinated removal internals. +- `src/list.rs`: expose/reuse formatting or row-building helpers as needed without changing default output. +- `e2e/`: add pseudo-terminal driven tests for interactive delete and coordinated/dirty behavior. +- `docs/user-guide.md` and `README.md` if needed: document `wtk delete` and its safety semantics. + +### Edge Cases + +- Non-TTY invocation of `wtk delete` fails immediately with a clear error. +- No deletable candidates exits without deletion and explains why. +- Empty selection or prompt cancellation exits without deletion. +- Any confirmation input other than exact `Y` cancels. +- Dirty worktrees are allowed only after `Y`; no stash/backup is attempted. +- Locked worktrees, broken refs, branch drift, and missing coordinated members fail visibly. +- Coordinated partial failure reports successful and failed member removals and returns non-zero. +- Branches remain after deletion. +- Long worktree/branch display fields are truncated with an ellipsis in the selector; full paths/branches appear in the confirmation summary. + +### Verification Strategy + +- Add black-box e2e tests for standalone interactive deletion, cancellation, exact `Y` confirmation, dirty force deletion, branch preservation, non-TTY failure, and coordinated cascading deletion. +- Re-run existing repository mode and auxiliary group e2e tests to ensure `wtk remove` and `wtk list` behavior remain compatible. +- Include at least one failure-path test where a selected coordinated set has a locked/broken member and the command reports failure non-zero without pretending success. diff --git a/specs/change/20260707-interactive-worktree-delete/spec.md b/specs/change/20260707-interactive-worktree-delete/spec.md new file mode 100644 index 0000000..3c00291 --- /dev/null +++ b/specs/change/20260707-interactive-worktree-delete/spec.md @@ -0,0 +1,102 @@ +--- +id: 20260707-interactive-worktree-delete +name: Interactive Worktree Delete +status: planned +created: '2026-07-07' +--- + +## Overview + +Users can already inspect Repository Worktrees with `wtk list`, but deleting more than one Repository Worktree is not yet an efficient guided CLI workflow. + +Goals: +- Add an interactive CLI deletion flow that displays a worktree list similar to `wtk list`. +- Let users select multiple Repository Worktrees with Space and confirm deletion with Enter. +- Support batch deletion from the terminal without requiring users to manually copy paths into repeated commands. + +Constraints: +- Follow the Zest Dev discussion flow before implementation. +- Record code, documentation, or web-confirmed facts with fact sources in the spec. +- Discuss and confirm large decision principles first; derive lower-level rules from those principles instead of asking about each detail individually. + +## Research + +See [design.md](./design.md). + +## Design + +### Design Summary + +Add `wtk delete` as a human-only interactive deletion command. With no arguments it opens a multi-select list that mirrors the existing flat `wtk list` scanning model, lets users select linked Repository Worktrees with Space, shows a full deletion summary, and executes only after the user types exactly `Y`. + +`wtk list` keeps its current default output. `wtk delete` reuses the same row data where possible, but treats coordinated Primary Repository worktrees as deletion roots: selecting one deletes the Primary Repository Worktree plus its recorded Auxiliary Repository Worktrees. Dirty selected worktrees are allowed and removed with force semantics after the explicit `Y` confirmation. The command does not delete branches. + +See [design.md](./design.md) for design detail. + +### E2E Acceptance Gate (EAG) + +Acceptance behavior: in a TTY-driven e2e scenario, `wtk delete` displays selectable worktrees, accepts Space/Enter selection, requires exact `Y` confirmation, removes selected standalone and coordinated worktree sets including dirty members, leaves branches intact, and reports partial failures with a non-zero exit. + +Verification path: add and run e2e coverage for interactive `wtk delete` using a pseudo-terminal/test harness, plus the existing repository/coordinated test suite. + +## Plan + +### Step 1 (AFK): Add `wtk delete` command shell + +Goal: Introduce the new command without changing existing `wtk list` or `wtk remove` behavior. +Scope: Add CLI parsing/help/dispatch for `wtk delete`, require TTY for no-arg interactive mode, handle help/non-TTY/unsupported args, and add focused parser/error tests. +Depends on: None + +### Step 2 (AFK): Build delete candidates from list-style worktree state + +Goal: Produce the flat selectable model that powers the interactive prompt and confirmation summary. +Scope: Reuse existing repository/list/coordinated-state facts to build candidates with display fields, truncation, dirty/current/main/protected markers, coordinated member expansion, and full summary data. +Depends on: Step 1 + +### Step 3 (AFK): Implement interactive selection and strict confirmation + +Goal: Deliver the human CLI flow before destructive execution. +Scope: Add `dialoguer` multi-select, render flat list rows similar to `wtk list`, support Space/Enter selection, cancellation/empty-selection exits, and exact uppercase `Y` line confirmation after a full deletion summary. +Depends on: Step 2 + +### Step 4 (AFK): Implement batch deletion execution + +Goal: Remove selected standalone and coordinated worktrees according to confirmed semantics. +Scope: Add force worktree removal for dirty selected worktrees, coordinated cascading deletion from selected Primary Repository Worktrees, branch preservation, structural preflight failures, state updates, per-item success/failure summaries, and non-zero exit on any failure. +Depends on: Step 3 + +### Step 5 (AFK): Add e2e coverage for interactive delete + +Goal: Prove the command works through realistic terminal behavior. +Scope: Add pseudo-terminal/test-harness coverage for standalone deletion, cancellation, non-`Y` cancellation, exact `Y` deletion, dirty deletion, branch preservation, non-TTY failure, coordinated cascade deletion, and at least one coordinated failure path. +Depends on: Step 4 + +### Step 6 (AFK): EAG Validation + +Goal: Validate the completed change against the Spec's EAG before wrap-up work. +Scope: Run the automated e2e verification path defined in the Design section: interactive `wtk delete` pseudo-terminal coverage plus existing repository/coordinated tests. +Depends on: Step 5 + +### Step 7 (AFK): Documentation Sync + +Goal: Keep project documentation aligned with the implemented behavior. +Scope: If the implementation changes documented behavior, usage, commands, setup, or workflows, update the relevant project docs. +Depends on: Step 6 + +## Progress + +- [ ] Step 1 (AFK): Add `wtk delete` command shell +- [ ] Step 2 (AFK): Build delete candidates from list-style worktree state +- [ ] Step 3 (AFK): Implement interactive selection and strict confirmation +- [ ] Step 4 (AFK): Implement batch deletion execution +- [ ] Step 5 (AFK): Add e2e coverage for interactive delete +- [ ] Step 6 (AFK): EAG Validation +- [ ] Step 7 (AFK): Documentation Sync + +## Implementation + +See [steps.md](./steps.md). + +## Deferred Follow-Ups (DFU) + +None. diff --git a/specs/change/20260707-interactive-worktree-delete/steps.md b/specs/change/20260707-interactive-worktree-delete/steps.md new file mode 100644 index 0000000..09d3405 --- /dev/null +++ b/specs/change/20260707-interactive-worktree-delete/steps.md @@ -0,0 +1,5 @@ +# Steps + +## Step 1 + + From a9594d6d4c6920a812c345339f3d74d9b16aaceb Mon Sep 17 00:00:00 2001 From: nettee Date: Tue, 7 Jul 2026 17:10:54 +0800 Subject: [PATCH 2/3] feat(worktree): add interactive delete command --- Cargo.lock | 236 +++++++++++++++ Cargo.toml | 1 + README.md | 2 +- docs/user-guide.md | 8 + e2e/conftest.py | 95 ++++++ e2e/test_interactive_delete.py | 115 ++++++++ .../spec.md | 16 +- .../steps.md | 46 ++- src/cli.rs | 56 ++++ src/worktree.rs | 274 ++++++++++++++++++ 10 files changed, 839 insertions(+), 10 deletions(-) create mode 100644 e2e/test_interactive_delete.py diff --git a/Cargo.lock b/Cargo.lock index 0a07273..b0cd6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.10.4" @@ -36,6 +42,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -55,6 +74,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -65,12 +97,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "generic-array" version = "0.14.7" @@ -81,6 +135,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "globset" version = "0.4.18" @@ -122,6 +187,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.33" @@ -134,6 +205,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -152,6 +229,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex-automata" version = "0.4.14" @@ -169,6 +252,19 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.23" @@ -251,6 +347,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "syn" version = "2.0.117" @@ -262,6 +364,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.23" @@ -315,6 +450,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -327,6 +468,94 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -340,6 +569,7 @@ dependencies = [ name = "wtk" version = "0.5.2" dependencies = [ + "dialoguer", "globset", "serde", "serde_json", @@ -348,6 +578,12 @@ dependencies = [ "toml", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index e3db767..d4cac4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ serde_yaml = "0.9" sha2 = "0.10" toml = "0.8" globset = "0.4" +dialoguer = "0.11" diff --git a/README.md b/README.md index d05e906..1ea5d89 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ wtk new feature/full-stack --ag full-stack The [User Guide](docs/user-guide.md) has the full workflow guide and command reference, including: - install, upgrade, and local source install -- creating, checking out, listing, and removing worktrees +- creating, checking out, listing, and deleting/removing worktrees - `send-out` and `bring-in` - Auxiliary Groups and coordinated worktrees - base branch selection diff --git a/docs/user-guide.md b/docs/user-guide.md index b85ec25..d86331b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -244,6 +244,14 @@ wtk list [--json] Lists visible worktrees in a compact table, or prints machine-readable JSON with `--json`. +### `wtk delete` + +```text +wtk delete +``` + +Opens an interactive terminal-only multi-select delete flow. Select linked worktrees with Space, press Enter, review the absolute-path summary, then type exactly `Y` to delete. Branches are preserved. Dirty selected worktrees are force-removed after confirmation. Selecting a coordinated Primary Repository worktree deletes its recorded coordinated set. + ### `wtk remove` ```text diff --git a/e2e/conftest.py b/e2e/conftest.py index f8bc12d..6628b9d 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -1,7 +1,10 @@ from __future__ import annotations import os +import pty +import select import shutil +import signal import subprocess import sys import time @@ -55,6 +58,26 @@ def describe(self) -> str: ) +@dataclass +class PtyResult: + args: list[str] + cwd: Path + returncode: int + output: str + + def assert_success(self) -> "PtyResult": + assert self.returncode == 0, self.describe() + return self + + def assert_failure(self) -> "PtyResult": + assert self.returncode != 0, f"command unexpectedly succeeded\n{self.describe()}" + return self + + def describe(self) -> str: + rendered = " ".join(self.args) + return f"command failed: {rendered}\ncwd: {self.cwd}\nexit: {self.returncode}\noutput:\n{self.output}" + + def run_cmd( args: list[str], *, @@ -86,6 +109,78 @@ def run_cmd( return result +def run_pty_cmd( + args: list[str], + *, + cwd: Path, + input_bytes: bytes = b"", + timeout: float = 10.0, + env: dict[str, str] | None = None, +) -> PtyResult: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + master_fd, slave_fd = pty.openpty() + proc = subprocess.Popen( + args, + cwd=cwd, + env=merged_env, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + ) + os.close(slave_fd) + output = bytearray() + sent = False + deadline = time.monotonic() + timeout + try: + while True: + if proc.poll() is not None: + while True: + ready, _, _ = select.select([master_fd], [], [], 0) + if not ready: + break + try: + chunk = os.read(master_fd, 4096) + except OSError: + break + if not chunk: + break + output.extend(chunk) + break + if time.monotonic() > deadline: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=1) + raise AssertionError( + f"PTY command timed out: {' '.join(args)}\noutput:\n" + + output.decode("utf-8", errors="replace") + ) + ready, _, _ = select.select([master_fd], [], [], 0.05) + if ready: + try: + chunk = os.read(master_fd, 4096) + except OSError: + chunk = b"" + if chunk: + output.extend(chunk) + if input_bytes and not sent and time.monotonic() < deadline - 0.1: + os.write(master_fd, input_bytes) + sent = True + return PtyResult( + args=args, + cwd=cwd, + returncode=proc.returncode, + output=output.decode("utf-8", errors="replace"), + ) + finally: + os.close(master_fd) + + def run_git(cwd: Path, *args: str, check: bool = True, env: dict[str, str] | None = None) -> CmdResult: return run_cmd(["git", *args], cwd=cwd, env=env, check=check) diff --git a/e2e/test_interactive_delete.py b/e2e/test_interactive_delete.py new file mode 100644 index 0000000..b10adf6 --- /dev/null +++ b/e2e/test_interactive_delete.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os + +import pytest + +from conftest import linked_worktree_path, run_git, run_pty_cmd + + +pytestmark = pytest.mark.skipif(os.name == "nt", reason="PTY helper requires Unix pseudo-terminals") + + +def run_delete_pty(wtk_bin, cwd, keys: bytes, timeout: float = 10.0): + return run_pty_cmd([str(wtk_bin), "delete"], cwd=cwd, input_bytes=keys, timeout=timeout) + + +def test_interactive_delete_standalone_space_enter_exact_y(wtk_bin, run_wtk, repo_factory) -> None: + repo = repo_factory.init_repo("repo") + run_git(repo, "branch", "feature/delete") + run_wtk("checkout", "feature/delete", "--no-clipboard", cwd=repo) + linked = linked_worktree_path(repo, "feature/delete") + + result = run_delete_pty(wtk_bin, repo, b" \rY\r").assert_success() + + assert not linked.exists() + assert "Deletion complete." in result.output + assert "feature/delete" in run_git(repo, "branch", "--list", "feature/delete").stdout + + +@pytest.mark.parametrize("keys", [b"\r", b" \rn\r"]) +def test_interactive_delete_cancels_empty_selection_or_non_y(wtk_bin, run_wtk, repo_factory, keys) -> None: + repo = repo_factory.init_repo("repo") + run_git(repo, "branch", "feature/cancel") + run_wtk("checkout", "feature/cancel", "--no-clipboard", cwd=repo) + linked = linked_worktree_path(repo, "feature/cancel") + + result = run_delete_pty(wtk_bin, repo, keys).assert_success() + + assert linked.exists() + assert "cancel" in result.output.lower() + + +def test_interactive_delete_removes_dirty_worktree(wtk_bin, run_wtk, repo_factory) -> None: + repo = repo_factory.init_repo("repo") + run_git(repo, "branch", "feature/dirty") + run_wtk("checkout", "feature/dirty", "--no-clipboard", cwd=repo) + linked = linked_worktree_path(repo, "feature/dirty") + (linked / "dirty.txt").write_text("dirty\n", encoding="utf-8") + + result = run_delete_pty(wtk_bin, repo, b" \rY\r").assert_success() + + assert not linked.exists() + assert "dirty: yes" in result.output + + +def test_interactive_delete_fails_without_tty(run_wtk, repo_factory) -> None: + repo = repo_factory.init_repo("repo") + + result = run_wtk("delete", cwd=repo, check=False) + + result.assert_failure() + assert "requires an interactive terminal" in result.output + + +def test_interactive_delete_coordinated_primary_cascades(wtk_bin, run_wtk, repo_factory) -> None: + primary = repo_factory.init_repo("primary") + api = repo_factory.init_repo("api") + run_wtk("ag", "add", "backend", str(api), cwd=primary) + run_wtk("new", "feature/coord", "--base", "main", "--ag", "backend", "--no-clipboard", cwd=primary) + primary_linked = linked_worktree_path(primary, "feature/coord") + api_linked = linked_worktree_path(api, "feature/coord") + + result = run_delete_pty(wtk_bin, primary, b" \rY\r").assert_success() + + assert not primary_linked.exists() + assert not api_linked.exists() + assert "coordinated members" in result.output + assert "feature/coord" in run_git(primary, "branch", "--list", "feature/coord").stdout + assert "feature/coord" in run_git(api, "branch", "--list", "feature/coord").stdout + + +def test_interactive_delete_coordinated_dirty_auxiliary_is_shown_before_confirmation(wtk_bin, run_wtk, repo_factory) -> None: + primary = repo_factory.init_repo("primary") + api = repo_factory.init_repo("api") + run_wtk("ag", "add", "backend", str(api), cwd=primary) + run_wtk("new", "feature/dirty-aux", "--base", "main", "--ag", "backend", "--no-clipboard", cwd=primary) + primary_linked = linked_worktree_path(primary, "feature/dirty-aux") + api_linked = linked_worktree_path(api, "feature/dirty-aux") + (api_linked / "dirty.txt").write_text("dirty\n", encoding="utf-8") + + result = run_delete_pty(wtk_bin, primary, b" \rn\r").assert_success() + + assert primary_linked.exists() + assert api_linked.exists() + assert f"{api_linked} dirty: yes" in result.output + + +def test_interactive_delete_broken_coordinated_row_does_not_abort_healthy_delete(wtk_bin, run_wtk, repo_factory) -> None: + primary = repo_factory.init_repo("primary") + api = repo_factory.init_repo("api") + run_wtk("ag", "add", "backend", str(api), cwd=primary) + run_wtk("new", "feature/broken", "--base", "main", "--ag", "backend", "--no-clipboard", cwd=primary) + run_git(primary, "branch", "feature/healthy") + run_wtk("checkout", "feature/healthy", "--no-clipboard", cwd=primary) + primary_linked = linked_worktree_path(primary, "feature/broken") + api_linked = linked_worktree_path(api, "feature/broken") + healthy_linked = linked_worktree_path(primary, "feature/healthy") + run_git(api, "worktree", "remove", "--force", str(api_linked)) + + result = run_delete_pty(wtk_bin, primary, b" \rY\r", timeout=5.0).assert_success() + + assert primary.exists() + assert primary_linked.exists() + assert not healthy_linked.exists() + assert "Deletion complete." in result.output diff --git a/specs/change/20260707-interactive-worktree-delete/spec.md b/specs/change/20260707-interactive-worktree-delete/spec.md index 3c00291..fa017b5 100644 --- a/specs/change/20260707-interactive-worktree-delete/spec.md +++ b/specs/change/20260707-interactive-worktree-delete/spec.md @@ -1,7 +1,7 @@ --- id: 20260707-interactive-worktree-delete name: Interactive Worktree Delete -status: planned +status: implemented created: '2026-07-07' --- @@ -85,13 +85,13 @@ Depends on: Step 6 ## Progress -- [ ] Step 1 (AFK): Add `wtk delete` command shell -- [ ] Step 2 (AFK): Build delete candidates from list-style worktree state -- [ ] Step 3 (AFK): Implement interactive selection and strict confirmation -- [ ] Step 4 (AFK): Implement batch deletion execution -- [ ] Step 5 (AFK): Add e2e coverage for interactive delete -- [ ] Step 6 (AFK): EAG Validation -- [ ] Step 7 (AFK): Documentation Sync +- [x] Step 1 (AFK): Add `wtk delete` command shell +- [x] Step 2 (AFK): Build delete candidates from list-style worktree state +- [x] Step 3 (AFK): Implement interactive selection and strict confirmation +- [x] Step 4 (AFK): Implement batch deletion execution +- [x] Step 5 (AFK): Add e2e coverage for interactive delete +- [x] Step 6 (AFK): EAG Validation +- [x] Step 7 (AFK): Documentation Sync ## Implementation diff --git a/specs/change/20260707-interactive-worktree-delete/steps.md b/specs/change/20260707-interactive-worktree-delete/steps.md index 09d3405..5d484f4 100644 --- a/specs/change/20260707-interactive-worktree-delete/steps.md +++ b/specs/change/20260707-interactive-worktree-delete/steps.md @@ -2,4 +2,48 @@ ## Step 1 - +Added `wtk delete` CLI parsing, help, dispatch, completion list entry, unsupported argument failures, and pre-prompt stdin/stdout TTY enforcement. + +Verification: added parser unit coverage; `cargo check` passed before formatting. + +## Step 2 + +Built delete candidates from the same repository row/list data path used by `wtk list`, excluding main/current, locked/error, auxiliary-side, and structurally invalid coordinated rows. + +Verification: `cargo check` passed before formatting. + +## Step 3 + +Added `dialoguer` MultiSelect selection flow and exact literal `Y` line confirmation; empty selection and non-`Y` confirmation cancel successfully. + +Verification: `cargo check` passed before formatting. + +## Step 4 + +Implemented confirmed batch deletion with forced worktree removal for dirty selections, coordinated primary cascade deletion, branch preservation, per-item success/failure output, and non-zero result on partial failure. + +Verification: `cargo check` passed before formatting. + +## Step 5 + +Added Unix PTY-driven e2e coverage for interactive `wtk delete`, including standalone Space/Enter selection plus exact `Y`, cancellation by empty selection and non-`Y`, dirty worktree deletion, branch preservation, non-TTY failure, coordinated primary cascade deletion, and a coordinated structural failure/protection path. + +Verification: `uv run pytest e2e/test_interactive_delete.py` passed (7 tests). + +Follow-up fix: coordinated delete candidates now determine and display dirty state for each member, exclude broken coordinated rows with diagnostics instead of aborting the whole selector, use cancelable prompt interaction, and keep selector order aligned with `wtk list`. Added regression coverage for dirty auxiliary summary and broken coordinated row isolation. + +Follow-up verification: `cargo fmt && cargo check && cargo test && uv run pytest e2e/test_interactive_delete.py` passed (8 interactive e2e tests). + +## Step 6 + +Ran the EAG verification path for interactive delete and existing repository/coordinated coverage. + +Verification: `uv run pytest e2e/test_interactive_delete.py` passed (7 tests); `uv run pytest e2e/test_repo_mode.py e2e/test_auxiliary_group.py` passed (46 tests). + +Follow-up verification: `uv run pytest e2e/test_repo_mode.py e2e/test_auxiliary_group.py` passed (46 tests). + +## Step 7 + +Updated README/User Guide command documentation for interactive delete behavior. + +Verification: documentation-only sync; no docs build configured. diff --git a/src/cli.rs b/src/cli.rs index 0db6bb1..9d680d0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,6 +19,7 @@ const TOP_LEVEL_COMMANDS: &[&str] = &[ "list", "init-worktree", "remove", + "delete", "send-out", "bring-in", "auxiliary-group", @@ -40,6 +41,7 @@ enum Parsed { ignored_env_snapshot_root: Option, }, Remove(Options), + Delete, SendOut(Options), BringIn(Options), AuxiliaryGroupAdd { @@ -156,6 +158,16 @@ where worktree::remove(session, options) }) } + Parsed::Delete => { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + let _ = writeln!( + stderr, + "wtk delete requires an interactive terminal on stdin and stdout" + ); + return Err(1); + } + execute_worktree(stdout, stderr, true, worktree::delete_interactive) + } Parsed::SendOut(options) => { execute_worktree(stdout, stderr, options.no_clipboard, |session| { worktree::send_out(session, options) @@ -292,6 +304,7 @@ fn parse_args(args: &[String]) -> Result { "list" => parse_list(rest), "init-worktree" => parse_init_worktree(rest), "remove" => parse_remove(rest), + "delete" => parse_delete(rest), "send-out" => parse_send_out(rest), "bring-in" => parse_bring_in(rest), "auxiliary-group" | "ag" => parse_auxiliary_group(rest), @@ -550,6 +563,23 @@ fn parse_remove(args: &[String]) -> Result { Ok(Parsed::Remove(options)) } +fn parse_delete(args: &[String]) -> Result { + let usage = command_help("delete"); + if args.len() == 1 { + return Ok(Parsed::Delete); + } + if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") { + return Ok(Parsed::HelpText(usage)); + } + if args[1].starts_with('-') { + return Err(UsageError::new(format!("unknown flag: {}", args[1]), usage)); + } + Err(UsageError::new( + format!("unexpected argument: {}", args[1]), + usage, + )) +} + fn parse_send_out(args: &[String]) -> Result { let usage = command_help("send-out"); let mut options = Options::default(); @@ -728,6 +758,7 @@ fn root_help() -> &'static str { " status Print current repository/worktree status as YAML\n", " list List visible worktrees in a compact table\n", " remove Remove a linked worktree\n", + " delete Interactively delete linked worktrees\n", " send-out Move the current main-worktree branch to a linked worktree\n", " bring-in Move a linked worktree branch back into the main worktree\n", " auxiliary-group Manage Auxiliary Groups\n", @@ -793,6 +824,12 @@ fn command_help(command: &str) -> &'static str { " --delete-branch\n", " --no-clipboard\n", ), + "delete" => concat!( + "Usage: wtk delete [flags]\n\n", + "Interactively select linked worktrees to delete. Branches are preserved.\n\n", + "Flags:\n", + " -h, --help\n", + ), "send-out" => concat!( "Usage: wtk send-out [flags]\n\n", "Flags:\n", @@ -1034,6 +1071,25 @@ mod tests { assert!(matches!(parsed, Parsed::List(options) if options.json)); } + #[test] + fn parses_delete_subcommand() { + let parsed = parse_args(&["wtk".to_string(), "delete".to_string()]).unwrap(); + assert!(matches!(parsed, Parsed::Delete)); + + let help = parse_args(&[ + "wtk".to_string(), + "delete".to_string(), + "--help".to_string(), + ]) + .unwrap(); + assert!(matches!(help, Parsed::HelpText(text) if text.contains("wtk delete"))); + + let error = parse_args(&["wtk".to_string(), "delete".to_string(), "x".to_string()]) + .err() + .unwrap(); + assert!(error.reason.contains("unexpected argument")); + } + #[test] fn complete_top_level_commands() { let candidates = diff --git a/src/worktree.rs b/src/worktree.rs index 2ce7d49..17d5eca 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -10,6 +10,7 @@ use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; +use std::io; use std::io::Write; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; @@ -480,6 +481,279 @@ pub fn list(session: &mut Session<'_>, options: ListOptions) -> AppResult<()> { ) } +#[derive(Debug, Clone)] +struct DeleteCandidate { + path: PathBuf, + branch: Option, + dirty: bool, + coordinated: bool, + members: Vec, +} + +#[derive(Debug, Clone)] +struct DeleteMember { + path: PathBuf, + dirty: bool, +} + +pub fn delete_interactive(session: &mut Session<'_>) -> AppResult<()> { + let repo = repo(session)?; + let state = auxiliary::read_state(&repo.main_root, &repo.git_common_dir)?; + let updated_at_by_head = + list::commit_timestamps_by_head(&session.git, &repo.main_root, &repo.worktrees); + let mut candidates = Vec::new(); + let mut excluded = Vec::new(); + let mut rows = Vec::new(); + for worktree in &repo.worktrees { + rows.push(list::repository_row( + &session.git, + &repo, + worktree, + &updated_at_by_head, + )); + } + for row in list::sorted_rows(rows) { + let Some(worktree) = repo.worktree_by_path(&row.path) else { + continue; + }; + let mut row = list::repository_row(&session.git, &repo, worktree, &updated_at_by_head); + let mut members = vec![DeleteMember { + path: worktree.path.clone(), + dirty: row.dirty, + }]; + let mut coordinated = false; + if let Some(entry) = auxiliary::worktree_entry(&state, &worktree.path) { + coordinated = true; + let ignored_refs = auxiliary::ignored_ref_paths(entry); + row = list::repository_row_with_options( + &session.git, + &repo, + worktree, + Some(&ignored_refs), + &updated_at_by_head, + ); + row.kind = "primary_worktree"; + let mut diagnostics = Vec::new(); + if let Err(error) = + auxiliary::validate_primary_worktree_branch(worktree, &entry.branch, &worktree.path) + { + diagnostics.push(error.to_string()); + } + if let Err(error) = auxiliary::validate_refs(&session.git, &worktree.path, entry) { + diagnostics.push(error.to_string()); + } + match auxiliary_delete_members(&session.git, entry) { + Ok(auxiliary_members) => members.extend(auxiliary_members), + Err(error) => diagnostics.push(error.to_string()), + } + row.diagnostics.extend(diagnostics); + } + if row.is_main || row.is_current || row.labels.iter().any(|label| label == "locked") { + continue; + } + if !row.diagnostics.is_empty() { + excluded.push(format!( + "{}: {}", + worktree.path.display(), + row.diagnostics.join("; ") + )); + continue; + } + if auxiliary::read_auxiliary_marker(&session.git, &worktree.path)?.is_some() { + continue; + } + candidates.push(DeleteCandidate { + path: worktree.path.clone(), + branch: row.branch.clone(), + dirty: row.dirty, + coordinated, + members, + }); + } + for diagnostic in &excluded { + writeln!(session.out, "Skipping non-deletable worktree: {diagnostic}")?; + } + if candidates.is_empty() { + writeln!(session.out, "No deletable linked worktrees found.")?; + return Ok(()); + } + let items = candidates + .iter() + .map(|candidate| { + let branch = candidate.branch.as_deref().unwrap_or("detached"); + let dirty = if candidate.dirty { " dirty" } else { "" }; + let coordinated = if candidate.coordinated { + " coordinated" + } else { + "" + }; + format!( + "{} [{}{}{}]", + candidate.path.display(), + branch, + dirty, + coordinated + ) + }) + .collect::>(); + let selected = dialoguer::MultiSelect::new() + .with_prompt("Select worktrees to delete (Space to select, Enter to continue)") + .items(&items) + .interact_opt() + .map_err(|error| Error::message(format!("interactive selection failed: {error}")))?; + let Some(selected) = selected else { + writeln!(session.out, "No worktrees selected; cancelled.")?; + return Ok(()); + }; + if selected.is_empty() { + writeln!(session.out, "No worktrees selected; cancelled.")?; + return Ok(()); + } + writeln!( + session.out, + "The following worktrees will be deleted; branches will be preserved:" + )?; + for index in &selected { + let candidate = &candidates[*index]; + writeln!(session.out, "- path: {}", candidate.path.display())?; + writeln!( + session.out, + " branch: {}", + candidate.branch.as_deref().unwrap_or("detached") + )?; + writeln!( + session.out, + " dirty: {}", + if candidate.dirty { "yes" } else { "no" } + )?; + if candidate.coordinated { + writeln!(session.out, " coordinated members:")?; + for member in &candidate.members { + writeln!( + session.out, + " - {} dirty: {}", + member.path.display(), + if member.dirty { "yes" } else { "no" } + )?; + } + } + } + writeln!(session.out, "Type Y to delete selected worktrees:")?; + session.out.flush()?; + let mut confirmation = String::new(); + io::stdin().read_line(&mut confirmation)?; + if confirmation.trim_end_matches(['\r', '\n']) != "Y" { + writeln!(session.out, "Cancelled.")?; + return Ok(()); + } + let mut failures = Vec::new(); + for index in selected { + let candidate = &candidates[index]; + match delete_candidate(session, &repo, candidate) { + Ok(()) => writeln!(session.out, "Deleted {}", candidate.path.display())?, + Err(error) => { + writeln!( + session.out, + "Failed {}: {}", + candidate.path.display(), + error + )?; + failures.push(format!("{}: {}", candidate.path.display(), error)); + } + } + } + if failures.is_empty() { + writeln!(session.out, "Deletion complete.")?; + Ok(()) + } else { + Err(Error::message(format!( + "{} deletion(s) failed", + failures.len() + ))) + } +} + +fn delete_candidate( + session: &mut Session<'_>, + repo: &RepoContext, + candidate: &DeleteCandidate, +) -> AppResult<()> { + let worktree = repo.worktree_by_path(&candidate.path).ok_or_else(|| { + Error::message(format!( + "target is not a linked worktree: {}", + candidate.path.display() + )) + })?; + if worktree.locked.is_some() { + return Err(Error::message(format!( + "worktree is locked: {}", + candidate.path.display() + ))); + } + let mut state = auxiliary::read_state(&repo.main_root, &repo.git_common_dir)?; + if let Some(entry) = auxiliary::worktree_entry(&state, &candidate.path).cloned() { + auxiliary::validate_primary_worktree_branch(worktree, &entry.branch, &candidate.path)?; + auxiliary::validate_refs(&session.git, &candidate.path, &entry)?; + validate_auxiliary_worktrees_removable(&session.git, &entry)?; + for auxiliary in entry.auxiliaries.values() { + remove_git_worktree_force(session, &auxiliary.repository, &auxiliary.worktree)?; + } + remove_git_worktree_force(session, &repo.main_root, &candidate.path)?; + auxiliary::remove_worktree_entry(&mut state, &candidate.path); + auxiliary::write_state(&session.git, &repo.main_root, &state)?; + } else { + if auxiliary::read_auxiliary_marker(&session.git, &candidate.path)?.is_some() { + return Err(Error::message( + "delete is not supported for auxiliary-side worktrees", + )); + } + remove_git_worktree_force(session, &repo.main_root, &candidate.path)?; + } + Ok(()) +} + +fn auxiliary_delete_members(git: &Git, entry: &WorktreeEntry) -> AppResult> { + let mut members = Vec::new(); + for (name, auxiliary) in &entry.auxiliaries { + let repo = resolve(git, &auxiliary.worktree)?; + let worktree = repo.worktree_by_path(&auxiliary.worktree).ok_or_else(|| { + Error::message(format!( + "Auxiliary worktree {name} is missing from git worktree list at {}", + auxiliary.repository.display() + )) + })?; + if let Some(reason) = &worktree.locked { + let detail = if reason.is_empty() { + String::new() + } else { + format!(": {reason}") + }; + return Err(Error::message(format!( + "Auxiliary worktree {name} at {} is locked{}", + auxiliary.worktree.display(), + detail + ))); + } + members.push(DeleteMember { + path: auxiliary.worktree.clone(), + dirty: worktree_dirty(git, &auxiliary.worktree)?, + }); + } + Ok(members) +} + +fn worktree_dirty(git: &Git, path: &Path) -> AppResult { + let output = git + .run(path, ["status", "--porcelain=v1", "--untracked-files=all"]) + .map_err(|error| { + Error::message(format!( + "failed to read dirty state for {}: {error}", + path.display() + )) + })?; + Ok(output.stdout.lines().any(|line| !line.is_empty())) +} + pub fn init_worktree( session: &mut Session<'_>, source_root: &Path, From 40dcc16384b474af05513b49335c49aa8626da8e Mon Sep 17 00:00:00 2001 From: nettee Date: Tue, 7 Jul 2026 17:43:23 +0800 Subject: [PATCH 3/3] fix(worktree): use terminal prompt for delete confirmation Generated-By: looper 0.10.1 (runner=fixer, agent=codex) --- src/worktree.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/worktree.rs b/src/worktree.rs index 17d5eca..10e3a9a 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -10,7 +10,6 @@ use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; -use std::io; use std::io::Write; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; @@ -638,11 +637,12 @@ pub fn delete_interactive(session: &mut Session<'_>) -> AppResult<()> { } } } - writeln!(session.out, "Type Y to delete selected worktrees:")?; - session.out.flush()?; - let mut confirmation = String::new(); - io::stdin().read_line(&mut confirmation)?; - if confirmation.trim_end_matches(['\r', '\n']) != "Y" { + let confirmation = dialoguer::Input::::new() + .with_prompt("Type Y to delete selected worktrees") + .allow_empty(true) + .interact_text() + .map_err(|error| Error::message(format!("interactive confirmation failed: {error}")))?; + if confirmation != "Y" { writeln!(session.out, "Cancelled.")?; return Ok(()); }