From fbc4bf9f34063712fdfe423310f924821ad7c871 Mon Sep 17 00:00:00 2001 From: samzong Date: Mon, 30 Mar 2026 13:37:48 +0800 Subject: [PATCH 1/2] fix(worktree): use status != clean for dirty check in prune GetWorktreeStatus returns detailed strings like '1 file changed' or '1 untracked', never the literal 'modified'. The previous equality check status == "modified" never matched, silently skipping the dirty worktree protection. Compare against 'clean' instead. Signed-off-by: samzong --- internal/worktree/prune.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/worktree/prune.go b/internal/worktree/prune.go index 217945e..4f74f7c 100644 --- a/internal/worktree/prune.go +++ b/internal/worktree/prune.go @@ -84,7 +84,7 @@ func (c *Client) Prune(opts PruneOptions) (PruneResult, error) { } status := c.GetWorktreeStatus(wt.Path) - if status == "modified" && !opts.Force { + if status != "clean" && !opts.Force { result.Warn(fmt.Sprintf("Skipped %s: worktree has uncommitted changes (use --force)", name)) continue } From fae2c40f84d86b8400d6864a6683c48a5e3840e5 Mon Sep 17 00:00:00 2001 From: samzong Date: Mon, 30 Mar 2026 13:38:46 +0800 Subject: [PATCH 2/2] feat(worktree): add --pr-aware flag to prune command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PR-aware pruning that checks GitHub PR state via gh CLI before deciding whether to remove a worktree. This internalizes the logic from skill/scripts/wt-cleanup.sh as native Go code. Key design decisions: - Single batch gh pr list call (not N+1 per branch) - ghRunFunc seam for testability without real gh binary - Report.PruneEntries carries structured data (no side-channel) - Shared collectPruneCandidates eliminates classic/pr-aware duplication - strings.ToUpper normalizes PR state for case-insensitive matching Decision matrix for --pr-aware: - MERGED + clean → remove (or would_remove with --dry-run) - MERGED + dirty → skip (unless --force) - CLOSED → skip (PR closed, not merged) - OPEN → skip (PR still open) - No PR → skip Closes #53 Signed-off-by: samzong --- cmd/worktree.go | 1 + cmd/worktree_prune.go | 41 ++++- internal/worktree/prune.go | 231 ++++++++++++++++++++++----- internal/worktree/prune_test.go | 272 ++++++++++++++++++++++++++++++++ 4 files changed, 505 insertions(+), 40 deletions(-) create mode 100644 internal/worktree/prune_test.go diff --git a/cmd/worktree.go b/cmd/worktree.go index f050dad..f157304 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -204,6 +204,7 @@ func init() { wtPruneCmd.Flags().StringVarP(&wtPruneBase, "base", "b", "", "Base branch to check merge status against") wtPruneCmd.Flags().BoolVarP(&wtPruneForce, "force", "f", false, "Force removal even if worktree is dirty") wtPruneCmd.Flags().BoolVar(&wtPruneDryRun, "dry-run", false, "Preview what would be removed without making changes") + wtPruneCmd.Flags().BoolVar(&wtPrunePRAware, "pr-aware", false, "Check GitHub PR state before pruning (requires gh CLI)") // Flags for pr-review command wtPrReviewCmd.Flags().StringVarP(&prRemote, "remote", "r", "", diff --git a/cmd/worktree_prune.go b/cmd/worktree_prune.go index 3179a37..ffd29ba 100644 --- a/cmd/worktree_prune.go +++ b/cmd/worktree_prune.go @@ -1,14 +1,18 @@ package cmd import ( + "fmt" + "text/tabwriter" + "github.com/samzong/gmc/internal/worktree" "github.com/spf13/cobra" ) var ( - wtPruneBase string - wtPruneForce bool - wtPruneDryRun bool + wtPruneBase string + wtPruneForce bool + wtPruneDryRun bool + wtPrunePRAware bool ) var wtPruneCmd = &cobra.Command{ @@ -17,7 +21,10 @@ var wtPruneCmd = &cobra.Command{ Long: `Remove worktrees whose branches are already merged into the base branch. This command uses pure git ancestry checks to decide which worktrees are safe to remove. -By default it removes both the worktree directory and the local branch.`, +By default it removes both the worktree directory and the local branch. + +Use --pr-aware to check GitHub PR state (via gh CLI) before deciding whether +to remove each worktree. Only worktrees with MERGED PRs are removed.`, RunE: func(_ *cobra.Command, _ []string) error { wtClient := newWorktreeClient() return runWorktreePrune(wtClient) @@ -36,12 +43,17 @@ func runWorktreePrune(wtClient *worktree.Client) error { BaseBranch: wtPruneBase, Force: wtPruneForce, DryRun: wtPruneDryRun, + PRAware: wtPrunePRAware, } result, err := wtClient.Prune(opts) if err != nil { return err } + if outputFormat() == "json" { + if opts.PRAware { + return printJSON(outWriter(), result.PruneEntries) + } action := "removed" if opts.DryRun { action = "would-remove" @@ -57,6 +69,27 @@ func runWorktreePrune(wtClient *worktree.Client) error { } return printJSON(outWriter(), items) } + printWorktreeReport(result.Report) + if len(result.PruneEntries) > 0 { + printPruneTable(result.PruneEntries) + } return nil } + +func printPruneTable(entries []worktree.PruneEntry) { + w := tabwriter.NewWriter(outWriter(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tBRANCH\tPR\tPR STATE\tACTION\tREASON") + for _, e := range entries { + pr := "-" + if e.PRNum > 0 { + pr = fmt.Sprintf("#%d", e.PRNum) + } + state := e.PRState + if state == "" { + state = "none" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", e.Name, e.Branch, pr, state, e.Action, e.Reason) + } + w.Flush() +} diff --git a/internal/worktree/prune.go b/internal/worktree/prune.go index 4f74f7c..64fb928 100644 --- a/internal/worktree/prune.go +++ b/internal/worktree/prune.go @@ -1,20 +1,31 @@ package worktree import ( + "encoding/json" "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "github.com/samzong/gmc/internal/gitutil" ) -// PruneOptions options for pruning merged worktrees. type PruneOptions struct { - BaseBranch string // Base branch to check merge status against - Force bool // Force removal even if worktree is dirty - DryRun bool // Preview what would be removed without making changes + BaseBranch string + Force bool + DryRun bool + PRAware bool +} + +type PruneEntry struct { + Name string `json:"name"` + Branch string `json:"branch"` + PRNum int `json:"pr_number,omitempty"` + PRState string `json:"pr_state"` + Action string `json:"action"` + Reason string `json:"reason"` } type PruneCandidate struct { @@ -25,10 +36,26 @@ type PruneCandidate struct { type PruneResult struct { Report - Candidates []PruneCandidate + Candidates []PruneCandidate + PruneEntries []PruneEntry +} + +type pruneCandidate struct { + wt Info + name string +} + +var ghRunFunc = ghRunDefault + +func ghRunDefault(repoDir string, args ...string) ([]byte, error) { + if _, err := exec.LookPath("gh"); err != nil { + return nil, fmt.Errorf("gh CLI not found: install from https://cli.github.com") + } + cmd := exec.Command("gh", args...) + cmd.Dir = repoDir + return cmd.Output() } -// Prune removes worktrees whose branches are merged into the base branch. func (c *Client) Prune(opts PruneOptions) (PruneResult, error) { var result PruneResult @@ -42,16 +69,29 @@ func (c *Client) Prune(opts PruneOptions) (PruneResult, error) { return result, err } + candidates, repoDir, err := c.collectPruneCandidates(root, baseBranch, &result.Report) + if err != nil { + return result, err + } + + if opts.PRAware { + return c.prunePRAware(opts, candidates, repoDir, result) + } + return c.pruneClassic(opts, candidates, root, baseBranch, repoDir, result) +} + +func (c *Client) collectPruneCandidates(root, baseBranch string, report *Report) ([]pruneCandidate, string, error) { baseBranchName := localBranchName(baseBranch) worktrees, err := c.List() if err != nil { - return result, err + return nil, "", err } repoDir := repoDirForGit(root) isBare := repoDir != root - var prunedAny bool + + var candidates []pruneCandidate for _, wt := range worktrees { if wt.IsBare || filepath.Base(wt.Path) == ".bare" || wt.Path == root { continue @@ -59,65 +99,162 @@ func (c *Client) Prune(opts PruneOptions) (PruneResult, error) { if isBare && isExternalPath(root, wt.Path) { continue } - name := filepath.Base(wt.Path) if wt.IsLocked { - result.Warn(fmt.Sprintf("Skipped %s: worktree is locked", name)) + if report != nil { + report.Warn(fmt.Sprintf("Skipped %s: worktree is locked", name)) + } continue } if wt.Branch == "" || wt.Branch == "(detached)" { - result.Warn(fmt.Sprintf("Skipped %s: detached HEAD", name)) + if report != nil { + report.Warn(fmt.Sprintf("Skipped %s: detached HEAD", name)) + } continue } if wt.Branch == baseBranchName { - result.Warn(fmt.Sprintf("Skipped %s: base branch '%s'", name, baseBranchName)) + if report != nil { + report.Warn(fmt.Sprintf("Skipped %s: base branch '%s'", name, baseBranchName)) + } continue } + candidates = append(candidates, pruneCandidate{wt: wt, name: name}) + } + + return candidates, repoDir, nil +} + +type ghPRInfo struct { + Number int `json:"number"` + State string `json:"state"` + HeadRefName string `json:"headRefName"` +} + +func ghPRStates(repoDir string) (map[string]ghPRInfo, error) { + out, err := ghRunFunc(repoDir, + "pr", "list", + "--state", "all", + "--json", "number,state,headRefName", + "--limit", "300", + ) + if err != nil { + return nil, fmt.Errorf("gh pr list failed: %w", err) + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" || trimmed == "[]" { + return map[string]ghPRInfo{}, nil + } + + var prs []ghPRInfo + if err := json.Unmarshal([]byte(trimmed), &prs); err != nil { + return nil, fmt.Errorf("failed to parse gh output: %w", err) + } + + m := make(map[string]ghPRInfo, len(prs)) + for _, pr := range prs { + pr.State = strings.ToUpper(pr.State) + if _, exists := m[pr.HeadRefName]; !exists { + m[pr.HeadRefName] = pr + } + } + return m, nil +} + +func (c *Client) prunePRAware(opts PruneOptions, candidates []pruneCandidate, repoDir string, result PruneResult) (PruneResult, error) { + prMap, err := ghPRStates(repoDir) + if err != nil { + return result, err + } + + for _, cand := range candidates { + pr, hasPR := prMap[cand.wt.Branch] + + entry := PruneEntry{ + Name: cand.name, + Branch: cand.wt.Branch, + } + + if hasPR { + entry.PRNum = pr.Number + entry.PRState = pr.State + } + + switch { + case hasPR && pr.State == "MERGED": + status := c.GetWorktreeStatus(cand.wt.Path) + if status != "clean" && !opts.Force { + entry.Action = "skipped" + entry.Reason = "PR merged but worktree has uncommitted changes" + result.PruneEntries = append(result.PruneEntries, entry) + continue + } + if opts.DryRun { + entry.Action = "would_remove" + entry.Reason = "PR merged" + result.PruneEntries = append(result.PruneEntries, entry) + continue + } + if err := c.removeWorktreeAndBranch(repoDir, cand.wt.Path, cand.wt.Branch, opts.Force, &result.Report); err != nil { + return result, err + } + entry.Action = "removed" + entry.Reason = "PR merged" + case hasPR && pr.State == "CLOSED": + entry.Action = "skipped" + entry.Reason = "PR closed, not merged" + case hasPR && pr.State == "OPEN": + entry.Action = "skipped" + entry.Reason = "PR still open" + default: + entry.Action = "skipped" + entry.Reason = "no PR found" + } + + result.PruneEntries = append(result.PruneEntries, entry) + } + + if len(result.PruneEntries) == 0 { + result.Warn("No worktrees to evaluate.") + } + + return result, nil +} - merged, err := c.isBranchMerged(root, wt.Branch, baseBranch) +func (c *Client) pruneClassic(opts PruneOptions, candidates []pruneCandidate, root, baseBranch, repoDir string, result PruneResult) (PruneResult, error) { + var prunedAny bool + + for _, cand := range candidates { + merged, err := c.isBranchMerged(root, cand.wt.Branch, baseBranch) if err != nil { - result.Warn(fmt.Sprintf("Skipped %s: %v", name, err)) + result.Warn(fmt.Sprintf("Skipped %s: %v", cand.name, err)) continue } if !merged { continue } - status := c.GetWorktreeStatus(wt.Path) + status := c.GetWorktreeStatus(cand.wt.Path) if status != "clean" && !opts.Force { - result.Warn(fmt.Sprintf("Skipped %s: worktree has uncommitted changes (use --force)", name)) + result.Warn(fmt.Sprintf("Skipped %s: worktree has uncommitted changes (use --force)", cand.name)) continue } - candidate := PruneCandidate{Name: name, Branch: wt.Branch, Status: status} + candidate := PruneCandidate{Name: cand.name, Branch: cand.wt.Branch, Status: status} if opts.DryRun { - result.Warn("Would remove worktree: " + wt.Path) - result.Warn(" Branch: " + wt.Branch) + result.Warn("Would remove worktree: " + cand.wt.Path) + result.Warn(" Branch: " + cand.wt.Branch) result.Warn(" Status: " + status) - result.Warn("Would delete branch: " + wt.Branch) + result.Warn("Would delete branch: " + cand.wt.Branch) result.Candidates = append(result.Candidates, candidate) prunedAny = true continue } - args := []string{"-C", repoDir, "worktree", "remove"} - if opts.Force { - args = append(args, "--force") - } - args = append(args, wt.Path) - - gitResult, err := c.runner.RunLogged(args...) - if err != nil { - return result, gitutil.WrapGitError("failed to remove worktree", gitResult, err) + if err := c.removeWorktreeAndBranch(repoDir, cand.wt.Path, cand.wt.Branch, opts.Force, &result.Report); err != nil { + return result, err } - result.Warn(fmt.Sprintf("Removed worktree '%s'", name)) - - gitResult, err = c.runner.RunLogged("-C", repoDir, "branch", "-D", wt.Branch) - if err != nil { - return result, gitutil.WrapGitError("failed to delete branch", gitResult, err) - } - result.Warn(fmt.Sprintf("Deleted branch '%s'", wt.Branch)) result.Candidates = append(result.Candidates, candidate) prunedAny = true } @@ -129,6 +266,28 @@ func (c *Client) Prune(opts PruneOptions) (PruneResult, error) { return result, nil } +func (c *Client) removeWorktreeAndBranch(repoDir, wtPath, branch string, force bool, report *Report) error { + name := filepath.Base(wtPath) + args := []string{"-C", repoDir, "worktree", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, wtPath) + + gitResult, err := c.runner.RunLogged(args...) + if err != nil { + return gitutil.WrapGitError("failed to remove worktree", gitResult, err) + } + report.Warn(fmt.Sprintf("Removed worktree '%s'", name)) + + gitResult, err = c.runner.RunLogged("-C", repoDir, "branch", "-D", branch) + if err != nil { + return gitutil.WrapGitError("failed to delete branch", gitResult, err) + } + report.Warn(fmt.Sprintf("Deleted branch '%s'", branch)) + return nil +} + func (c *Client) resolveBaseBranch(root string, override string) (string, error) { return c.resolveBaseBranchWithPolicy(repoDirForGit(root), override, true) } diff --git a/internal/worktree/prune_test.go b/internal/worktree/prune_test.go new file mode 100644 index 0000000..818257d --- /dev/null +++ b/internal/worktree/prune_test.go @@ -0,0 +1,272 @@ +package worktree + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestGhPRStates_ParsesBatchResponse(t *testing.T) { + prs := []ghPRInfo{ + {Number: 10, State: "MERGED", HeadRefName: "feat-a"}, + {Number: 11, State: "OPEN", HeadRefName: "feat-b"}, + {Number: 12, State: "CLOSED", HeadRefName: "feat-c"}, + } + data, _ := json.Marshal(prs) + + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(repoDir string, args ...string) ([]byte, error) { + return data, nil + } + + m, err := ghPRStates("/tmp") + if err != nil { + t.Fatalf("ghPRStates() error = %v", err) + } + if len(m) != 3 { + t.Fatalf("expected 3 entries, got %d", len(m)) + } + if m["feat-a"].State != "MERGED" || m["feat-a"].Number != 10 { + t.Errorf("feat-a = %+v", m["feat-a"]) + } + if m["feat-b"].State != "OPEN" { + t.Errorf("feat-b = %+v", m["feat-b"]) + } + if m["feat-c"].State != "CLOSED" { + t.Errorf("feat-c = %+v", m["feat-c"]) + } +} + +func TestGhPRStates_NormalizesCase(t *testing.T) { + data := `[{"number":1,"state":"merged","headRefName":"br"}]` + + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(repoDir string, args ...string) ([]byte, error) { + return []byte(data), nil + } + + m, err := ghPRStates("/tmp") + if err != nil { + t.Fatalf("ghPRStates() error = %v", err) + } + if m["br"].State != "MERGED" { + t.Errorf("expected MERGED, got %q", m["br"].State) + } +} + +func TestGhPRStates_EmptyResponse(t *testing.T) { + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(repoDir string, args ...string) ([]byte, error) { + return []byte("[]"), nil + } + + m, err := ghPRStates("/tmp") + if err != nil { + t.Fatalf("ghPRStates() error = %v", err) + } + if len(m) != 0 { + t.Errorf("expected empty map, got %d entries", len(m)) + } +} + +func TestGhPRStates_FirstPRWins(t *testing.T) { + data := `[{"number":1,"state":"OPEN","headRefName":"br"},{"number":2,"state":"MERGED","headRefName":"br"}]` + + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(repoDir string, args ...string) ([]byte, error) { + return []byte(data), nil + } + + m, err := ghPRStates("/tmp") + if err != nil { + t.Fatalf("ghPRStates() error = %v", err) + } + if m["br"].Number != 1 { + t.Errorf("expected first PR (#1), got #%d", m["br"].Number) + } +} + +func TestPrunePRAware_DecisionMatrix(t *testing.T) { + repoDir := initTestRepo(t) + + branches := []struct { + name string + prState string + }{ + {"feat-merged", "MERGED"}, + {"feat-open", "OPEN"}, + {"feat-closed", "CLOSED"}, + {"feat-nopr", ""}, + } + + for _, b := range branches { + wtDir := filepath.Join(repoDir, b.name) + runGit(t, repoDir, "worktree", "add", "-b", b.name, wtDir, "main") + } + + data, _ := json.Marshal([]ghPRInfo{ + {Number: 10, State: "MERGED", HeadRefName: "feat-merged"}, + {Number: 11, State: "OPEN", HeadRefName: "feat-open"}, + {Number: 12, State: "CLOSED", HeadRefName: "feat-closed"}, + }) + + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(dir string, args ...string) ([]byte, error) { + return data, nil + } + + cwd, _ := os.Getwd() + defer func() { _ = os.Chdir(cwd) }() + _ = os.Chdir(repoDir) + + client := NewClient(Options{}) + result, err := client.Prune(PruneOptions{DryRun: true, PRAware: true}) + if err != nil { + t.Fatalf("Prune() error = %v", err) + } + + entryMap := make(map[string]PruneEntry) + for _, e := range result.PruneEntries { + entryMap[e.Branch] = e + } + + tests := []struct { + branch string + wantAction string + wantPRNum int + }{ + {"feat-merged", "would_remove", 10}, + {"feat-open", "skipped", 11}, + {"feat-closed", "skipped", 12}, + {"feat-nopr", "skipped", 0}, + } + + for _, tt := range tests { + e, ok := entryMap[tt.branch] + if !ok { + t.Errorf("missing entry for %s", tt.branch) + continue + } + if e.Action != tt.wantAction { + t.Errorf("%s: action = %q, want %q", tt.branch, e.Action, tt.wantAction) + } + if e.PRNum != tt.wantPRNum { + t.Errorf("%s: PRNum = %d, want %d", tt.branch, e.PRNum, tt.wantPRNum) + } + } +} + +func TestPrunePRAware_GhFailure(t *testing.T) { + repoDir := initTestRepo(t) + + wtDir := filepath.Join(repoDir, "feat-x") + runGit(t, repoDir, "worktree", "add", "-b", "feat-x", wtDir, "main") + + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(dir string, args ...string) ([]byte, error) { + return nil, fmt.Errorf("auth required") + } + + cwd, _ := os.Getwd() + defer func() { _ = os.Chdir(cwd) }() + _ = os.Chdir(repoDir) + + client := NewClient(Options{}) + _, err := client.Prune(PruneOptions{PRAware: true}) + if err == nil { + t.Fatal("expected error when gh fails") + } +} + +func TestPrunePRAware_DirtyWorktreeSkipped(t *testing.T) { + repoDir := initTestRepo(t) + + wtDir := filepath.Join(repoDir, "feat-dirty") + runGit(t, repoDir, "worktree", "add", "-b", "feat-dirty", wtDir, "main") + writeFile(t, filepath.Join(wtDir, "dirty.txt"), "staged") + runGit(t, wtDir, "add", "dirty.txt") + runGit(t, wtDir, "commit", "-m", "add file") + writeFile(t, filepath.Join(wtDir, "dirty.txt"), "modified after commit") + + data := `[{"number":1,"state":"MERGED","headRefName":"feat-dirty"}]` + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(dir string, args ...string) ([]byte, error) { + return []byte(data), nil + } + + cwd, _ := os.Getwd() + defer func() { _ = os.Chdir(cwd) }() + _ = os.Chdir(repoDir) + + client := NewClient(Options{}) + result, err := client.Prune(PruneOptions{PRAware: true}) + if err != nil { + t.Fatalf("Prune() error = %v", err) + } + + if len(result.PruneEntries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result.PruneEntries)) + } + e := result.PruneEntries[0] + if e.Action != "skipped" { + t.Errorf("action = %q, want skipped", e.Action) + } + if _, err := os.Stat(wtDir); os.IsNotExist(err) { + t.Error("worktree should NOT have been removed") + } +} + +func TestPrunePRAware_ForceRemovesDirty(t *testing.T) { + repoDir := initTestRepo(t) + + wtDir := filepath.Join(repoDir, "feat-dirty2") + runGit(t, repoDir, "worktree", "add", "-b", "feat-dirty2", wtDir, "main") + writeFile(t, filepath.Join(wtDir, "dirty.txt"), "staged") + runGit(t, wtDir, "add", "dirty.txt") + runGit(t, wtDir, "commit", "-m", "add file") + writeFile(t, filepath.Join(wtDir, "dirty.txt"), "modified after commit") + + data := `[{"number":1,"state":"MERGED","headRefName":"feat-dirty2"}]` + orig := ghRunFunc + defer func() { ghRunFunc = orig }() + ghRunFunc = func(dir string, args ...string) ([]byte, error) { + return []byte(data), nil + } + + cwd, _ := os.Getwd() + defer func() { _ = os.Chdir(cwd) }() + _ = os.Chdir(repoDir) + + client := NewClient(Options{}) + result, err := client.Prune(PruneOptions{PRAware: true, Force: true}) + if err != nil { + t.Fatalf("Prune() error = %v", err) + } + + if len(result.PruneEntries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result.PruneEntries)) + } + e := result.PruneEntries[0] + if e.Action != "removed" { + t.Errorf("action = %q, want removed", e.Action) + } + + if _, err := os.Stat(wtDir); !os.IsNotExist(err) { + t.Error("worktree should have been removed with --force") + } + + cmd := exec.Command("git", "-C", repoDir, "rev-parse", "--verify", "refs/heads/feat-dirty2") + if err := cmd.Run(); err == nil { + t.Error("expected branch to be deleted") + } +}