Skip to content

feat(clean): add interactive worktree cleanup command#89

Open
fdarian wants to merge 11 commits intosatococoa:mainfrom
fdarian:wtp-clean
Open

feat(clean): add interactive worktree cleanup command#89
fdarian wants to merge 11 commits intosatococoa:mainfrom
fdarian:wtp-clean

Conversation

@fdarian
Copy link

@fdarian fdarian commented Feb 15, 2026

Closes #88

Summary

  • Adds wtp clean command for interactively reviewing and removing stale worktrees
  • Validates worktrees in parallel for merge status, uncommitted changes, and unpushed commits
  • Presents a tabular multi-select UI with safe/unsafe indicators and pre-selected safe worktrees

Flow

wtp clean [--force]
  │
  ├─ Fetch all worktrees via `git worktree list --porcelain`
  ├─ Load wtp config from main worktree (for base_dir)
  ├─ Filter to wtp-managed worktrees only (exclude main)
  │
  ├─ Validate each worktree (in parallel):
  │   ├─ Merge status:  `git merge-base --is-ancestor <branch> <main>`
  │   ├─ Clean status:  `git -C <path> status --porcelain`
  │   └─ Push status:   `git rev-list --count origin/<branch>..<branch>`
  │
  ├─ Build tabular options with aligned columns (WORKTREE / STATUS / NOTE)
  ├─ Show interactive multi-select form (charmbracelet/huh)
  │   └─ Safe worktrees pre-selected, unsafe shown with reasons
  │
  └─ Remove selected worktrees via `git worktree remove [--force]`

Validation Logic

A worktree is safe when all three checks pass:

Check How Unsafe reason
Merged Branch is ancestor of main branch unmerged or detached HEAD
Clean git status --porcelain is empty uncommitted changes
Pushed No commits ahead of remote tracking branch unpushed commits (N ahead)

Safe worktrees are pre-selected for removal. Unsafe worktrees are shown but not pre-selected — the user can still choose to remove them.

The --force flag is passed through to git worktree remove for worktrees with uncommitted changes.

Changes

File What
cmd/wtp/clean.go Core clean command: validation, UI, removal logic
cmd/wtp/clean_test.go Comprehensive unit tests (validation, filtering, options building)
cmd/wtp/app.go Register clean subcommand
internal/command/builders.go Git command builders for status/merge/push checks
go.mod / go.sum Add charmbracelet/huh dependency for interactive forms

Test Plan

  • Unit tests cover validation logic (merge, clean, push checks)
  • Unit tests cover worktree filtering (managed vs unmanaged)
  • Unit tests cover tabular option building and alignment
  • Manual test: run wtp clean in repo with mixed safe/unsafe worktrees
  • Manual test: verify --force removes worktrees with uncommitted changes

Summary by CodeRabbit

  • New Features

    • Added an interactive "clean" CLI command to list, validate, and remove non-main worktrees with status checks and a multi-select prompt.
  • Tests

    • Added a comprehensive test suite covering command behavior, worktree validation scenarios, parsing, error handling, and edge cases.

Add GitStatusPorcelain and GitLogNotPushed commands to support
the new clean command's validation logic.
Add wtp clean command with interactive checkbox UI using charmbracelet/huh.
Worktrees are pre-selected if they are:
- Fully merged into the main branch
- Have no uncommitted changes
- Have no unpushed commits

Supports both arrow keys and vim keys (j/k) for navigation.
Add tests for validation logic, option building, and command execution.
Covers safe/unsafe worktree detection and edge cases.
Validate worktrees concurrently using goroutines instead of
sequentially. Also hoists detectMainBranch out of the per-worktree
loop so it's called once rather than N times.
Align option labels into three columns (name, status, note) with
dynamic padding based on the longest worktree name. Add a column
header via the MultiSelect Description field.
Add 4-space left padding to the column header to account for the
huh MultiSelect bullet prefix (> • / ○).
@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

📝 Walkthrough

Walkthrough

Adds a new interactive wtp clean command with worktree validation and removal, accompanying tests, two new git command builders, and updated UI/utility dependencies required for the interactive form.

Changes

Cohort / File(s) Summary
Clean Command
cmd/wtp/app.go, cmd/wtp/clean.go
Adds NewCleanCommand() and full wtp clean implementation: lists & filters managed worktrees, parallel validation (merged, clean, pushed), interactive multi-select with pre-selected safe worktrees, and git worktree removal with optional --force.
Tests
cmd/wtp/clean_test.go
Adds comprehensive unit and integration-style tests with mocks covering validation logic, parsing of git worktree list, main branch detection, option building, removal flows, edge cases (detached HEAD, intl chars), and error paths.
Command builders
internal/command/builders.go
Adds GitStatusPorcelain(path string) Command and GitLogNotPushed(branch string) Command helpers used for status and not-pushed checks.
Dependencies
go.mod
Adds/updates indirect dependencies for interactive UI and utilities (charmbracelet/huh, bubbletea, bubbles, catppuccin/go, go-humanize, and related modules).

Sequence Diagram

sequenceDiagram
    participant User
    participant CleanCmd as Clean Command
    participant Executor as Git Executor
    participant Validator as Validator
    participant Form as Interactive Form
    participant Remover as Remover

    User->>CleanCmd: run `wtp clean`
    CleanCmd->>Executor: git worktree list
    Executor-->>CleanCmd: worktrees + main path
    CleanCmd->>Validator: load config, filter managed worktrees
    Validator-->>CleanCmd: managed worktrees

    CleanCmd->>Validator: validate worktrees (parallel)
    par Checks per worktree
        Validator->>Executor: git merge-base / main branch
        Validator->>Executor: git status --porcelain (porcelain)
        Validator->>Executor: git log (not pushed)
    end
    Validator-->>CleanCmd: statuses (safe/unsafe + reasons)

    CleanCmd->>Form: build multi-select options (safe pre-selected)
    Form-->>CleanCmd: selected worktrees

    CleanCmd->>Remover: remove selected
    par Removals
        Remover->>Executor: git worktree remove [--force]
        Executor-->>Remover: result
    end
    Remover-->>User: report successes/failures
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

codex

Suggested reviewers

  • satococoa

Poem

🐰 A hop through branches, tidy and keen,

I sniff merged worktrees, shiny and clean.
Safe ones pre-picked, the list looks just right,
I nudge away leftovers into the night.
Hooray — less clutter, more room to bite!

🚥 Pre-merge checks | ✅ 5 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: adding an interactive worktree cleanup command.
Linked Issues check ✅ Passed All requirements from issue #88 are implemented: validation checks (merge, clean, pushed), clear overview with safety reasons, interactive multi-select UI with pre-selected safe worktrees, and --force flag support.
Out of Scope Changes check ✅ Passed All changes directly support the interactive cleanup command: clean.go implements the feature, clean_test.go provides comprehensive tests, app.go registers the command, builders.go adds required git commands, and go.mod adds necessary UI dependencies.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@cmd/wtp/clean_test.go`:
- Around line 761-790: The mockCleanCommandExecutor.matchCommand currently
treats any command with arg0 == "rev-list" the same, so two different rev-list
calls from checkPushStatus (origin/<branch>..<branch> vs <main>..<branch>) both
return m.results["rev-list"] and never m.results["ahead-cmd"]; update
matchCommand to inspect cmd.Args (use strings.Contains or join) to distinguish
the two rev-list invocations and return m.results["ahead-cmd"] for the rev-list
that computes the ahead count (the origin/<branch>..<branch> or the specific
pattern used by checkPushStatus), leaving the other rev-list to return
m.results["rev-list"]; reference function names matchCommand and checkPushStatus
and map keys "rev-list" and "ahead-cmd".
- Around line 279-342: The test TestBuildCleanOptions is missing assertions for
pre-selected counts and cannot inspect huh.Option[string] selected state because
huh.Option only exposes Selected(bool) setter; update buildCleanOptions to
return a testable representation (e.g., change its return type or add a helper
like cleanOptionsWithState containing options []huh.Option[string] and Selected
[]bool or a slice of struct{ Opt huh.Option[string]; Selected bool }) so the
selection flag is produced alongside each option, update buildCleanOptions (and
any callers) to populate that Selected value based on
worktreeCleanStatus.isSafe, and then add assertions in TestBuildCleanOptions to
compare tt.expectedSelected and tt.expectedUnselected against the returned
Selected flags while still verifying len(opts.options) and option keys;
alternatively, if you prefer minimal change, introduce a thin wrapper type or
factory used by buildCleanOptions that records selection state for tests and
assert against that wrapper in the test.

In `@cmd/wtp/clean.go`:
- Around line 120-138: The function getWorktreesForClean currently dereferences
result.Results[0].Output without validating result or its Results slice; update
getWorktreesForClean to first check that result is non-nil and
len(result.Results) > 0 and return a descriptive error (e.g.,
errors.GitCommandFailed("git worktree list", "no results returned") or similar)
if the slice is empty or result is nil, then safely use result.Results[0].Output
to call parseWorktreesFromOutput; reference getWorktreesForClean and
result.Results[0].Output when making the change.
- Around line 305-322: In checkCleanStatus, don't ignore the error from
executor.Execute and avoid dereferencing a nil result; mirror the safe pattern
used in checkMergeStatus by capturing the error return from executor.Execute,
return (or set s.isClean=false) when an error occurs, and check result != nil
before accessing result.Results; update the function (checkCleanStatus) to
handle the error and nil result cases so it never calls len(result.Results) or
accesses result.Results[0] when result is nil.
- Around line 281-303: The checkMergeStatus function currently discards the
error returned by executor.Execute and then accesses result.Results which can
panic if result is nil; update checkMergeStatus to capture and check the error
from executor.Execute (called with mergeBaseCmd) and only access result.Results
when err == nil and result != nil, setting s.isMerged/s.isSafe and appending
reasons when an error occurs or when the command indicates unmerged; apply the
same guard-and-error-handling pattern to the other affected methods:
checkCleanStatus, checkPushStatus, and getWorktreesForClean so they validate the
returned error and result before dereferencing result.
- Around line 324-366: In checkPushStatus, don't ignore errors or assume
executor.Execute returns a non-nil result: for both calls to executor.Execute
inside checkPushStatus (the pushCheckCmd and aheadCmd invocations) capture the
returned error and check that result != nil and len(result.Results) > 0 before
accessing result.Results[0].Output; if error != nil or result is nil/empty,
return early (or treat as no output) to avoid a nil dereference, and keep
existing logic that sets s.isPushed/s.isSafe and appends reasons only when valid
output is present.
- Around line 256-279: The detectMainBranch function currently prefers "master"
when both exist and uses git rev-parse on the literal "main"; change it to
prefer "main" first and to determine the default branch by executing and parsing
the output of "git symbolic-ref refs/remotes/origin/HEAD" (via the executor) to
extract the branch name (strip "refs/remotes/origin/"), falling back to checking
explicit "main" and then "master" with rev-parse if symbolic-ref fails; update
the commands built in detectMainBranch and the return logic so it returns the
parsed default branch when available, otherwise "main" or "master" as fallbacks.

In `@go.mod`:
- Line 64: The go.mod entry for github.com/charmbracelet/huh is marked as
indirect but the package is imported directly (see cmd/wtp/clean.go where huh is
used); run `go mod tidy` to update go.mod so huh appears in the direct require
block (or manually move the require for github.com/charmbracelet/huh v0.8.0 into
the main require list) and then commit the updated go.mod and go.sum.
🧹 Nitpick comments (4)
cmd/wtp/clean.go (1)

20-29: Consider consolidating reason and reasons fields.

worktreeCleanStatus carries both reason string and reasons []string, where reason is always derived from reasons via buildReason(). Keeping both creates a risk of them getting out of sync if buildReason() is forgotten. Consider computing the display string on demand (e.g., a method) instead of storing it.

cmd/wtp/clean_test.go (2)

668-686: os.Chdir in test mutates process-global state.

Lines 671-673 use os.Chdir which affects the entire process. This is fragile if tests are ever run in parallel. Consider using the cleanGetwd override (already defined in clean.go line 32) to inject the test directory instead.

Suggested approach
 func TestCleanCommand_NotInGitRepo(t *testing.T) {
-	// Create a temporary directory that is not a git repo
 	tempDir := t.TempDir()
-	oldDir, _ := os.Getwd()
-	defer func() { _ = os.Chdir(oldDir) }()
-	err := os.Chdir(tempDir)
-	assert.NoError(t, err)
+	originalGetwd := cleanGetwd
+	cleanGetwd = func() (string, error) { return tempDir, nil }
+	defer func() { cleanGetwd = originalGetwd }()

725-732: callCount field is unused.

mockCleanCommandExecutor.callCount is incremented on line 750 but never read or asserted in any test. Either remove it or add assertions that verify call counts where it matters.

internal/command/builders.go (1)

90-104: Remove or integrate unused command builders.

GitStatusPorcelain and GitLogNotPushed are defined but never called. The checkCleanStatus (line 306) and checkPushStatus (line 333) functions in cmd/wtp/clean.go build their commands inline instead:

  • checkCleanStatus uses WorkDir field with git status --porcelain (vs GitStatusPorcelain's -C flag approach)
  • checkPushStatus uses git rev-list --count origin/<branch>..<branch> (vs GitLogNotPushed's git log ... --not --remotes)

Either integrate these builders into the respective functions or remove them.

Comment on lines +279 to +342
func TestBuildCleanOptions(t *testing.T) {
tests := []struct {
name string
statuses []worktreeCleanStatus
expectedOptions int
expectedSelected int
expectedUnselected int
}{
{
name: "all safe worktrees - all pre-selected",
statuses: []worktreeCleanStatus{
{worktree: git.Worktree{Path: "/test/repo/.worktrees/feature1"}, isSafe: true, reason: "safe"},
{worktree: git.Worktree{Path: "/test/repo/.worktrees/feature2"}, isSafe: true, reason: "safe"},
},
expectedOptions: 2,
expectedSelected: 2,
expectedUnselected: 0,
},
{
name: "mix of safe and unsafe - only safe pre-selected",
statuses: []worktreeCleanStatus{
{worktree: git.Worktree{Path: "/test/repo/.worktrees/safe"}, isSafe: true, reason: "safe"},
{worktree: git.Worktree{Path: "/test/repo/.worktrees/unsafe"}, isSafe: false, reason: "unsafe"},
},
expectedOptions: 2,
expectedSelected: 1,
expectedUnselected: 1,
},
{
name: "all unsafe worktrees - none pre-selected",
statuses: []worktreeCleanStatus{
{worktree: git.Worktree{Path: "/test/repo/.worktrees/unsafe1"}, isSafe: false, reason: "unsafe"},
{worktree: git.Worktree{Path: "/test/repo/.worktrees/unsafe2"}, isSafe: false, reason: "unsafe"},
},
expectedOptions: 2,
expectedSelected: 0,
expectedUnselected: 2,
},
{
name: "empty statuses",
statuses: []worktreeCleanStatus{},
expectedOptions: 0,
expectedSelected: 0,
expectedUnselected: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
Defaults: config.Defaults{BaseDir: ".worktrees"},
}

opts := buildCleanOptions(tt.statuses, cfg, "/test/repo")

assert.Equal(t, tt.expectedOptions, len(opts.options))

// Verify options were created
for _, opt := range opts.options {
assert.NotEmpty(t, opt.Key)
}
})
}
}
Copy link

@coderabbitai coderabbitai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the exact test code at lines 279-342
cd /tmp && git ls-files | grep -E "cmd/wtp/clean_test\.go"

Repository: satococoa/wtp

Length of output: 125


🏁 Script executed:

# Read the test file to verify the claims
cat -n cmd/wtp/clean_test.go | sed -n '279,342p'

Repository: satococoa/wtp

Length of output: 2441


🏁 Script executed:

# Check the buildCleanOptions function signature and return type
rg -A 10 "func buildCleanOptions" cmd/wtp/

Repository: satococoa/wtp

Length of output: 691


🏁 Script executed:

# Search for the worktreeCleanOptions struct definition to understand its structure
rg -B 3 -A 10 "type.*CleanOptions|type.*Option struct" cmd/wtp/

Repository: satococoa/wtp

Length of output: 39


🏁 Script executed:

# Check if huh is being imported and what version
rg "github.com/charmbracelet/huh|import.*huh" cmd/wtp/

Repository: satococoa/wtp

Length of output: 159


🏁 Script executed:

# Get the complete buildCleanOptions function implementation
rg -A 30 "func buildCleanOptions" cmd/wtp/clean.go

Repository: satococoa/wtp

Length of output: 1086


🏁 Script executed:

# Search for cleanOptions struct definition
rg -B 2 -A 10 "type cleanOptions struct"  cmd/wtp/clean.go

Repository: satococoa/wtp

Length of output: 498


🏁 Script executed:

# Check go.mod for huh version
cat go.mod | grep huh

Repository: satococoa/wtp

Length of output: 105


🏁 Script executed:

# Search for how huh.Option is used in the codebase - particularly checking if Selected is a field
rg "\.Selected|huh\.Option" cmd/wtp/ -A 2

Repository: satococoa/wtp

Length of output: 692


🏁 Script executed:

# Let's check if there's documentation or code showing how to access Selected state from huh.Option
# First check if there's any test in huh itself or examples showing this
web_search

Repository: satococoa/wtp

Length of output: 107


🌐 Web query:

charmbracelet huh Option v0.8.0 Selected field getter

💡 Result:

In github.com/charmbracelet/huh v0.8.0, Option[T] only has Selected(selected bool) Option[T] (a setter)—there is no “selected” getter on Option. The way to read what’s selected is via the field value (binding/accessor), or (more generically) GetValue(). [1]

  • Select: read the chosen value via your bound variable (Value(&v)) or s.GetValue() (type-assert to T). [1]
  • MultiSelect: read the chosen values via your bound slice (Value(&vs)) or m.GetValue() (type-assert to []T). [1]

Example:

s := huh.NewSelect[string]().
  Options(huh.NewOptions("A", "B", "C")...)

// ...after s.Run() or form.Run():
v := s.GetValue().(string)

m := huh.NewMultiSelect[string]().
  Options(huh.NewOptions("A", "B", "C")...)

// ...after m.Run() or form.Run():
vs := m.GetValue().([]string)

If you meant “what option is the cursor on right now”, use Hovered() on Select/MultiSelect. [1]

Sources: pkg.go.dev API docs for huh (includes Option.Selected, Select.GetValue, MultiSelect.GetValue, Hovered). [1]


🏁 Script executed:

# Also check the actual huh library if it's in go.mod to understand the Option structure
# Let's look for any existing tests that check Selected state
rg "\.Selected\(\)" cmd/wtp/clean_test.go

Repository: satococoa/wtp

Length of output: 39


🏁 Script executed:

# Check if there are any other tests that verify selected/unselected states
rg -B 3 -A 3 "isSafe.*Selected|Selected.*isSafe" cmd/wtp/

Repository: satococoa/wtp

Length of output: 39


🏁 Script executed:

# Let's verify what properties/methods are available on huh.Option
# by checking how it's used in the actual code
rg "option\." cmd/wtp/clean.go

Repository: satococoa/wtp

Length of output: 90


🏁 Script executed:

# Check if there are any other ways the test validates selected state
rg -B 5 -A 5 "expectedSelected|expectedUnselected" cmd/wtp/clean_test.go

Repository: satococoa/wtp

Length of output: 1667


Add missing assertions for pre-selection counts — the suggested fix won't compile with huh v0.8.0.

The test correctly defines expectedSelected and expectedUnselected test cases but never asserts on them (lines 334–339 only check option count and key presence). The pre-selection logic is untested.

However, the suggested fix cannot work as written: huh.Option[string] in v0.8.0 has no getter for the selected state—Selected(bool) is only a setter method. To verify selected state, you must either:

  • Refactor buildCleanOptions to return selection state in a testable form (e.g., a struct containing both the option and selection flag)
  • Use reflection to inspect the internal state (not recommended)
  • Mock or wrap huh.Option with a custom type that tracks selection

Add the missing assertions once a verifiable approach is chosen.

🤖 Prompt for AI Agents
In `@cmd/wtp/clean_test.go` around lines 279 - 342, The test TestBuildCleanOptions
is missing assertions for pre-selected counts and cannot inspect
huh.Option[string] selected state because huh.Option only exposes Selected(bool)
setter; update buildCleanOptions to return a testable representation (e.g.,
change its return type or add a helper like cleanOptionsWithState containing
options []huh.Option[string] and Selected []bool or a slice of struct{ Opt
huh.Option[string]; Selected bool }) so the selection flag is produced alongside
each option, update buildCleanOptions (and any callers) to populate that
Selected value based on worktreeCleanStatus.isSafe, and then add assertions in
TestBuildCleanOptions to compare tt.expectedSelected and tt.expectedUnselected
against the returned Selected flags while still verifying len(opts.options) and
option keys; alternatively, if you prefer minimal change, introduce a thin
wrapper type or factory used by buildCleanOptions that records selection state
for tests and assert against that wrapper in the test.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The pre-selection test assertions cannot be added as suggested because huh.Option v0.8.0 has no getter for the selected state—Selected(bool) is only a setter. This requires a design decision on how to make the selection state testable. Potential approaches:

  1. Refactor buildCleanOptions to return selection state alongside options (e.g., a struct with both option and boolean flag)
  2. Use reflection to inspect internal state (not recommended)
  3. Mock or wrap huh.Option with a custom type

A follow-up issue can be opened to address this pending resolution.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fdarian, your analysis is correct—this is indeed a structural limitation that requires a design decision rather than a quick fix in this PR.

Opening a follow-up issue is a good approach to track this technical debt. Would you like me to create a GitHub issue with the context (the missing assertions, the huh.Option limitation, and the three potential approaches you outlined)? I can include a reference back to this PR and comment for continuity.


🧠 Learnings used
Learnt from: CR
Repo: satococoa/wtp PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-11T14:56:49.178Z
Learning: Run `go tool task test` for running tests

Prevent nil pointer dereferences when executor.Execute returns nil
result or empty Results slice in getWorktreesForClean, checkMergeStatus,
checkCleanStatus, and checkPushStatus.
The previous logic using rev-parse --abbrev-ref would skip "main" when
it existed, incorrectly falling through to check for "master". Simplify
to just check branch existence with --verify --quiet.
The mock now checks for "origin/" prefix in args to differentiate
between push-check and ahead-count rev-list commands.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@cmd/wtp/clean.go`:
- Around line 305-322: When executor.Execute in
worktreeCleanStatus.checkCleanStatus fails or returns nil/empty results, don't
return silently; instead mark the worktree as dirty and unsafe by setting
s.isClean = false, s.isSafe = false and append an explanatory reason (e.g., "git
status failed" plus any error text if available) so failures are fail-safe.
Apply the same principle in worktreeCleanStatus.checkPushStatus: when
executor.Execute (the push-check command) errors or yields no usable result, set
s.isPushed = false, s.isSafe = false and append a reason (e.g., "push check
failed" with error details if present). Ensure you use the existing
executor.Execute call sites and result handling in checkCleanStatus and
checkPushStatus to capture and log the error or nil-result case before
returning.
- Around line 228-236: The code calls
executor.Execute([]command.Command{removeCmd}) and immediately dereferences
result.Results, which can panic if result is nil; add a nil-guard like the other
helpers (checkMergeStatus/checkCleanStatus/checkPushStatus): after execErr
check, verify result != nil before accessing result.Results, and if result is
nil write a failure message to w (including name and that result was nil) and
continue; reference the executor.Execute call, the result variable, and
result.Results to locate the change.
🧹 Nitpick comments (3)
cmd/wtp/clean_test.go (3)

735-760: Mock executor only processes commands[0], silently ignoring batched commands.

If cleanCommandWithExecutor or any caller passes multiple commands in a single Execute call, only the first command's result is returned. This is fine if the production code always passes single-command slices (which appears to be the case here), but it's a subtle assumption worth documenting in the mock.


810-829: TestCleanCommand_EmptyWorktreeList duplicates TestCleanCommand_NoManagedWorktrees.

Both tests set up a mock with only the main worktree and assert "No managed worktrees found". They test the same code path. Consider removing one or differentiating them (e.g., test with a truly empty worktree list output).


669-687: TestCleanCommand_NotInGitRepo changes the process working directory, which is shared state.

os.Chdir affects all goroutines. If go test runs tests in parallel (via t.Parallel()), this could cause flaky failures in other tests. Currently no t.Parallel() calls exist, but this is fragile. Consider using the t.Chdir helper available in Go 1.24 which automatically restores the directory and is test-safe.

♻️ Proposed fix using Go 1.24's `t.Chdir`
 func TestCleanCommand_NotInGitRepo(t *testing.T) {
-	// Create a temporary directory that is not a git repo
 	tempDir := t.TempDir()
-	oldDir, _ := os.Getwd()
-	defer func() { _ = os.Chdir(oldDir) }()
-	err := os.Chdir(tempDir)
-	assert.NoError(t, err)
+	t.Chdir(tempDir)
 
 	app := &cli.Command{

Comment on lines +228 to +236
result, execErr := executor.Execute([]command.Command{removeCmd})
if execErr != nil {
_, _ = fmt.Fprintf(w, "Failed to remove '%s': %v\n", name, execErr)
continue
}
if len(result.Results) > 0 && result.Results[0].Error != nil {
_, _ = fmt.Fprintf(w, "Failed to remove '%s': %v\n", name, result.Results[0].Error)
continue
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing nil check on result before accessing result.Results.

If executor.Execute returns (nil, nil), line 233 will panic with a nil pointer dereference. The same nil-guard pattern used in checkMergeStatus, checkCleanStatus, and checkPushStatus should be applied here.

🐛 Proposed fix
 		result, execErr := executor.Execute([]command.Command{removeCmd})
 		if execErr != nil {
 			_, _ = fmt.Fprintf(w, "Failed to remove '%s': %v\n", name, execErr)
 			continue
 		}
-		if len(result.Results) > 0 && result.Results[0].Error != nil {
+		if result == nil || (len(result.Results) > 0 && result.Results[0].Error != nil) {
+			errMsg := "unknown error"
+			if result != nil && len(result.Results) > 0 && result.Results[0].Error != nil {
+				errMsg = result.Results[0].Error.Error()
+			}
-			_, _ = fmt.Fprintf(w, "Failed to remove '%s': %v\n", name, result.Results[0].Error)
+			_, _ = fmt.Fprintf(w, "Failed to remove '%s': %s\n", name, errMsg)
 			continue
 		}
🤖 Prompt for AI Agents
In `@cmd/wtp/clean.go` around lines 228 - 236, The code calls
executor.Execute([]command.Command{removeCmd}) and immediately dereferences
result.Results, which can panic if result is nil; add a nil-guard like the other
helpers (checkMergeStatus/checkCleanStatus/checkPushStatus): after execErr
check, verify result != nil before accessing result.Results, and if result is
nil write a failure message to w (including name and that result was nil) and
continue; reference the executor.Execute call, the result variable, and
result.Results to locate the change.

Comment on lines +305 to +322
func (s *worktreeCleanStatus) checkCleanStatus(wt git.Worktree, executor command.Executor) {
statusCmd := command.Command{
Name: "git",
Args: []string{"status", "--porcelain"},
WorkDir: wt.Path,
}
result, err := executor.Execute([]command.Command{statusCmd})
if err != nil || result == nil || len(result.Results) == 0 {
return
}

output := strings.TrimSpace(result.Results[0].Output)
if output != "" {
s.isClean = false
s.isSafe = false
s.reasons = append(s.reasons, "uncommitted changes")
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Silent failure in checkCleanStatus defaults to "clean", risking data loss.

If executor.Execute returns an error or nil result (line 312-313), the method returns early without marking the worktree as dirty. Since isClean defaults to true, a failed git status check will leave the worktree marked as safe for removal — potentially deleting a worktree with uncommitted changes.

The same concern applies to checkPushStatus (lines 337-340), where a failure defaults to "pushed".

🛡️ Proposed fix — fail-safe: treat errors as unsafe
 func (s *worktreeCleanStatus) checkCleanStatus(wt git.Worktree, executor command.Executor) {
 	statusCmd := command.Command{
 		Name:    "git",
 		Args:    []string{"status", "--porcelain"},
 		WorkDir: wt.Path,
 	}
 	result, err := executor.Execute([]command.Command{statusCmd})
 	if err != nil || result == nil || len(result.Results) == 0 {
+		s.isClean = false
+		s.isSafe = false
+		s.reasons = append(s.reasons, "uncommitted changes (check failed)")
 		return
 	}
🤖 Prompt for AI Agents
In `@cmd/wtp/clean.go` around lines 305 - 322, When executor.Execute in
worktreeCleanStatus.checkCleanStatus fails or returns nil/empty results, don't
return silently; instead mark the worktree as dirty and unsafe by setting
s.isClean = false, s.isSafe = false and append an explanatory reason (e.g., "git
status failed" plus any error text if available) so failures are fail-safe.
Apply the same principle in worktreeCleanStatus.checkPushStatus: when
executor.Execute (the push-check command) errors or yields no usable result, set
s.isPushed = false, s.isSafe = false and append a reason (e.g., "push check
failed" with error details if present). Ensure you use the existing
executor.Execute call sites and result handling in checkCleanStatus and
checkPushStatus to capture and log the error or nil-result case before
returning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Interactive worktree cleanup command (wtp clean)

1 participant