From d8463c5b60f02eaf0f4f7284703273583ae674ae Mon Sep 17 00:00:00 2001 From: ppn26 Date: Wed, 1 Apr 2026 11:57:18 +0200 Subject: [PATCH 1/6] test: add integration tests for cd command gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers interactive mode gaps, --copy flag, history sorting, all-stale-history path, and label-scope resolution — five new TestCd_* integration tests. Co-Authored-By: Claude Opus 4.6 --- cmd/wt/cd_integration_test.go | 261 ++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/cmd/wt/cd_integration_test.go b/cmd/wt/cd_integration_test.go index 774519f..23c0367 100644 --- a/cmd/wt/cd_integration_test.go +++ b/cmd/wt/cd_integration_test.go @@ -441,3 +441,264 @@ func TestCd_NoArgs_StaleHistory(t *testing.T) { t.Errorf("expected path %q, got %q", wtPath, got) } } + +// TestCd_HistorySorting tests that wt cd (no args) returns the most recently accessed worktree. +// +// Scenario: Two worktrees exist with history entries at different timestamps +// Expected: The worktree with the more recent LastAccess is returned +func TestCd_HistorySorting(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + wtPath1 := createTestWorktree(t, repoPath, "older-branch") + wtPath2 := createTestWorktree(t, repoPath, "newer-branch") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + historyPath := filepath.Join(tmpDir, ".wt", "history.json") + + // Record history entries: wtPath1 accessed 2 hours ago, wtPath2 accessed 1 hour ago + hist := &history.History{ + Entries: []history.Entry{ + { + Path: wtPath1, + RepoName: "myrepo", + Branch: "older-branch", + LastAccess: time.Now().Add(-2 * time.Hour), + }, + { + Path: wtPath2, + RepoName: "myrepo", + Branch: "newer-branch", + LastAccess: time.Now().Add(-1 * time.Hour), + }, + }, + } + if err := hist.Save(historyPath); err != nil { + t.Fatalf("failed to save history: %v", err) + } + + cfg := &config.Config{ + RegistryPath: regFile, + HistoryPath: historyPath, + } + ctx, out := testContextWithConfigAndOutput(t, cfg, repoPath) + + cmd := newCdCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cd command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if got != wtPath2 { + t.Errorf("expected most recently accessed path %q, got %q", wtPath2, got) + } +} + +// TestCd_NoWorktrees_BranchArg tests error when a registered repo has no worktrees and branch arg is used. +// +// Scenario: Repo is registered but has no extra worktrees, user runs `wt cd feature` +// Expected: Returns "worktree not found" error +func TestCd_NoWorktrees_BranchArg(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + // Setup a repo but create NO additional worktrees + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, repoPath) + + cmd := newCdCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"feature"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when branch has no worktree, got nil") + } + if !strings.Contains(err.Error(), "worktree not found") { + t.Errorf("expected 'worktree not found' error, got %q", err.Error()) + } +} + +// TestCd_CopyFlag tests that --copy flag does not prevent path output. +// +// Scenario: User runs `wt cd --copy feature` +// Expected: Path is still printed to stdout; clipboard failure is a warning not an error +func TestCd_CopyFlag(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + wtPath := createTestWorktree(t, repoPath, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx, out := testContextWithConfigAndOutput(t, cfg, repoPath) + + cmd := newCdCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--copy", "feature"}) + + // Clipboard operations may fail in test environments; the command should + // still succeed and print the path to stdout. + if err := cmd.Execute(); err != nil { + t.Fatalf("cd --copy command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if got != wtPath { + t.Errorf("expected path %q, got %q", wtPath, got) + } +} + +// TestCd_AllStaleHistory tests error when all history entries are stale. +// +// Scenario: History has entries but all paths no longer exist +// Expected: Returns "no worktree history (all entries stale)" error +func TestCd_AllStaleHistory(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{Repos: []registry.Repo{}} + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + historyPath := filepath.Join(tmpDir, ".wt", "history.json") + + // Record only stale entries (paths that don't exist) + hist := &history.History{ + Entries: []history.Entry{ + { + Path: filepath.Join(tmpDir, "nonexistent-1"), + RepoName: "gone-repo", + Branch: "gone-branch-1", + LastAccess: time.Now().Add(-1 * time.Hour), + }, + { + Path: filepath.Join(tmpDir, "nonexistent-2"), + RepoName: "gone-repo", + Branch: "gone-branch-2", + LastAccess: time.Now().Add(-2 * time.Hour), + }, + }, + } + if err := hist.Save(historyPath); err != nil { + t.Fatalf("failed to save history: %v", err) + } + + cfg := &config.Config{ + RegistryPath: regFile, + HistoryPath: historyPath, + } + ctx := testContextWithConfig(t, cfg, tmpDir) + + cmd := newCdCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for all-stale history, got nil") + } + if !strings.Contains(err.Error(), "all entries stale") { + t.Errorf("expected 'all entries stale' error, got %q", err.Error()) + } +} + +// TestCd_LabelScope tests resolving a worktree via label:branch. +// +// Scenario: Two repos both have a "feature" worktree; one repo has label "team-a". +// User runs `wt cd team-a:feature`. +// Expected: Returns the path from the repo with label "team-a", not the other repo. +func TestCd_LabelScope(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repo1Path := setupTestRepo(t, tmpDir, "repo1") + repo2Path := setupTestRepo(t, tmpDir, "repo2") + wtPath1 := createTestWorktree(t, repo1Path, "feature") + createTestWorktree(t, repo2Path, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "repo1", Path: repo1Path, Labels: []string{"team-a"}}, + {Name: "repo2", Path: repo2Path}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx, out := testContextWithConfigAndOutput(t, cfg, repo1Path) + + cmd := newCdCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"team-a:feature"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cd command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if got != wtPath1 { + t.Errorf("expected path from repo with label 'team-a' %q, got %q", wtPath1, got) + } +} From df3a673d7184c4380a6dfdc5d790fd813cc14bd8 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Wed, 1 Apr 2026 12:00:28 +0200 Subject: [PATCH 2/6] test: add integration tests for config command gaps Co-Authored-By: Claude Opus 4.6 --- cmd/wt/config_integration_test.go | 259 ++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/cmd/wt/config_integration_test.go b/cmd/wt/config_integration_test.go index b43ad30..2dba6a4 100644 --- a/cmd/wt/config_integration_test.go +++ b/cmd/wt/config_integration_test.go @@ -4,10 +4,13 @@ package main import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" "github.com/raphi011/wt/internal/config" + "github.com/raphi011/wt/internal/registry" ) // TestConfigInit_Stdout tests printing default config to stdout. @@ -215,3 +218,259 @@ func TestConfigHooks_JSON_Empty(t *testing.T) { t.Errorf("expected null or {} for empty hooks, got %q", output) } } + +// TestConfigShow_WithLocalConfig tests config show --json when a local .wt.toml overrides values. +// +// Scenario: User is inside a repo that has a local .wt.toml with overrides, runs `wt config show --json` +// Expected: JSON output includes the local overrides (e.g. forge.default = "gitlab") +func TestConfigShow_WithLocalConfig(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + // Write a local .wt.toml that overrides forge.default + localCfgContent := `[forge] +default = "gitlab" +` + localCfgPath := filepath.Join(repoPath, config.LocalConfigFileName) + if err := os.WriteFile(localCfgPath, []byte(localCfgContent), 0644); err != nil { + t.Fatalf("failed to write local config: %v", err) + } + + cfg := &config.Config{ + Forge: config.ForgeConfig{ + Default: "github", + }, + } + + // workDir is set to the repo path so config show can detect the local config + ctx, out := testContextWithConfigAndOutput(t, cfg, repoPath) + + cmd := newConfigCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"show", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("config show --json with local config failed: %v", err) + } + + output := out.String() + if output == "" { + t.Fatal("expected JSON output, got empty") + } + + var result map[string]any + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput: %s", err, output) + } + + // Verify the local override took effect: Forge.Default should be "gitlab" + forge, ok := result["Forge"].(map[string]any) + if !ok { + t.Fatalf("expected 'Forge' key in JSON output, got: %v", result) + } + if forge["Default"] != "gitlab" { + t.Errorf("expected Forge.Default = 'gitlab' (from local override), got %q", forge["Default"]) + } +} + +// TestConfigInit_LocalStdout tests `config init --local --stdout`. +// +// Scenario: User runs `wt config init --local --stdout` +// Expected: Default local config TOML is printed, no error (--stdout path does not write files) +func TestConfigInit_LocalStdout(t *testing.T) { + t.Parallel() + + // --local --stdout prints to fmt.Printf (os.Stdout) and returns immediately without + // needing a repo or registry. We just verify no error. + ctx := testContext(t) + + cmd := newConfigCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"init", "--local", "--stdout"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("config init --local --stdout failed: %v", err) + } +} + +// TestConfigShow_PreservePatterns tests that preserve patterns appear in config show --json output. +// +// Scenario: Config has preserve patterns and excludes set, user runs `wt config show --json` +// Expected: JSON output includes the preserve patterns and excludes +func TestConfigShow_PreservePatterns(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Preserve: config.PreserveConfig{ + Patterns: []string{".env", ".env.local"}, + Exclude: []string{"node_modules", "dist"}, + }, + } + + ctx, out := testContextWithConfigAndOutput(t, cfg, t.TempDir()) + + cmd := newConfigCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"show", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("config show --json with preserve patterns failed: %v", err) + } + + output := out.String() + if output == "" { + t.Fatal("expected JSON output, got empty") + } + + var result map[string]any + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput: %s", err, output) + } + + preserve, ok := result["Preserve"].(map[string]any) + if !ok { + t.Fatalf("expected 'Preserve' key in JSON output, got: %v", result) + } + + patterns, ok := preserve["Patterns"].([]any) + if !ok || len(patterns) == 0 { + t.Errorf("expected non-empty Preserve.Patterns in JSON output, got: %v", preserve["Patterns"]) + } + + exclude, ok := preserve["Exclude"].([]any) + if !ok || len(exclude) == 0 { + t.Errorf("expected non-empty Preserve.Exclude in JSON output, got: %v", preserve["Exclude"]) + } +} + +// TestConfigHooks_WithRepo tests `config hooks --repo ` with a registered repo. +// +// Scenario: A repo is registered and has hooks both globally and in its local .wt.toml. +// User runs `wt config hooks --repo myrepo`. +// Expected: No error; command resolves the repo, loads local config, and displays hooks. +func TestConfigHooks_WithRepo(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + // Write a local .wt.toml with a hook override + localCfgContent := `[hooks.setup] +command = "npm install" +description = "Install dependencies" +on = ["checkout"] +` + localCfgPath := filepath.Join(repoPath, config.LocalConfigFileName) + if err := os.WriteFile(localCfgPath, []byte(localCfgContent), 0644); err != nil { + t.Fatalf("failed to write local config: %v", err) + } + + // Set up registry with the repo + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry dir: %v", err) + } + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{ + RegistryPath: regFile, + Hooks: config.HooksConfig{ + Hooks: map[string]config.Hook{ + "code": { + Command: "code {worktree-dir}", + Description: "Open in VS Code", + On: []string{"checkout"}, + }, + }, + }, + } + + ctx := testContextWithConfig(t, cfg, tmpDir) + + cmd := newConfigCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"hooks", "--repo", "myrepo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("config hooks --repo myrepo failed: %v", err) + } +} + +// TestConfigShow_RepoFlag tests `config show --json --repo ` with a registered repo. +// +// Scenario: A repo is registered with a local .wt.toml. User runs `wt config show --json --repo myrepo`. +// Expected: Valid JSON output contains the merged config (including local overrides). +func TestConfigShow_RepoFlag(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + // Write a local .wt.toml that overrides checkout.worktree_format + localCfgContent := `[checkout] +worktree_format = "custom/{branch}" +` + localCfgPath := filepath.Join(repoPath, config.LocalConfigFileName) + if err := os.WriteFile(localCfgPath, []byte(localCfgContent), 0644); err != nil { + t.Fatalf("failed to write local config: %v", err) + } + + // Set up registry with the repo + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry dir: %v", err) + } + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{ + RegistryPath: regFile, + Checkout: config.CheckoutConfig{ + WorktreeFormat: ".worktrees/{branch}", + }, + } + + ctx, out := testContextWithConfigAndOutput(t, cfg, tmpDir) + + cmd := newConfigCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"show", "--json", "--repo", "myrepo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("config show --json --repo myrepo failed: %v", err) + } + + output := out.String() + if output == "" { + t.Fatal("expected JSON output, got empty") + } + + var result map[string]any + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput: %s", err, output) + } + + // Verify the local override was applied: Checkout.WorktreeFormat should be from local config + checkout, ok := result["Checkout"].(map[string]any) + if !ok { + t.Fatalf("expected 'Checkout' key in JSON output, got: %v", result) + } + if checkout["WorktreeFormat"] != "custom/{branch}" { + t.Errorf("expected Checkout.WorktreeFormat = 'custom/{branch}' (from local override), got %q", checkout["WorktreeFormat"]) + } +} From ad85a8a3dc7acb9a45abdd2ad5a5ee00ddf163c3 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Wed, 1 Apr 2026 12:07:55 +0200 Subject: [PATCH 3/6] test: add integration tests for prune command gaps Adds 4 integration tests covering previously untested prune code paths: - Label-scoped targeting (team-a:feature prunes only matching repos) - Multiple worktrees per repo (prune one, verify others remain) - History cleanup after prune (history entry removed on worktree removal) - LocallyMerged auto-prune (merged branches auto-pruned without -f) Co-Authored-By: Claude Opus 4.6 --- cmd/wt/prune_integration_test.go | 235 +++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/cmd/wt/prune_integration_test.go b/cmd/wt/prune_integration_test.go index c591177..d79ed55 100644 --- a/cmd/wt/prune_integration_test.go +++ b/cmd/wt/prune_integration_test.go @@ -11,6 +11,7 @@ import ( "github.com/raphi011/wt/internal/config" "github.com/raphi011/wt/internal/forge" "github.com/raphi011/wt/internal/git" + "github.com/raphi011/wt/internal/history" "github.com/raphi011/wt/internal/registry" ) @@ -1758,3 +1759,237 @@ func TestPrune_ExplicitHookFlag(t *testing.T) { t.Error("default hook should NOT have run when --hook is used") } } + +// TestPrune_LabelScopedTarget tests pruning worktrees via label:branch format. +// +// Scenario: Two repos exist; only one has the label "team-a". User runs +// `wt prune team-a:feature -f` from an unrelated directory. +// Expected: Only the repo with label "team-a" has its worktree removed. +func TestPrune_LabelScopedTarget(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpDir = resolvePath(t, tmpDir) + + repo1Path := setupTestRepoWithBranches(t, tmpDir, "repo1", []string{"feature"}) + repo2Path := setupTestRepoWithBranches(t, tmpDir, "repo2", []string{"feature"}) + + wt1Path := createTestWorktree(t, repo1Path, "feature") + wt2Path := createTestWorktree(t, repo2Path, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry dir: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "repo1", Path: repo1Path, Labels: []string{"team-a"}}, + {Name: "repo2", Path: repo2Path}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + + // Run from a non-repo directory so scope resolution must rely on the label + otherDir := filepath.Join(tmpDir, "not-a-repo") + if err := os.MkdirAll(otherDir, 0755); err != nil { + t.Fatalf("failed to create non-repo dir: %v", err) + } + + ctx := testContextWithConfig(t, cfg, otherDir) + cmd := newPruneCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"team-a:feature", "-f"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("prune command failed: %v", err) + } + + // repo1 (labelled "team-a") should have its worktree removed + if _, err := os.Stat(wt1Path); err == nil { + t.Error("repo1 worktree (team-a) should be removed after label-scoped prune") + } + + // repo2 (no label) should be untouched + if _, err := os.Stat(wt2Path); os.IsNotExist(err) { + t.Error("repo2 worktree should NOT be removed (label 'team-a' does not match)") + } +} + +// TestPrune_MultipleWorktrees_PruneOne tests that pruning one worktree in a repo +// with multiple worktrees leaves the other worktrees intact. +// +// Scenario: Repo has three worktrees (feat-a, feat-b, feat-c). User prunes feat-b. +// Expected: feat-b is removed; feat-a and feat-c still exist. +func TestPrune_MultipleWorktrees_PruneOne(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpDir = resolvePath(t, tmpDir) + + repoPath := setupTestRepoWithBranches(t, tmpDir, "test-repo", []string{"feat-a", "feat-b", "feat-c"}) + wtAPath := createTestWorktree(t, repoPath, "feat-a") + wtBPath := createTestWorktree(t, repoPath, "feat-b") + wtCPath := createTestWorktree(t, repoPath, "feat-c") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry dir: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "test-repo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, repoPath) + cmd := newPruneCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"feat-b", "-f"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("prune command failed: %v", err) + } + + // feat-b should be removed + if _, err := os.Stat(wtBPath); err == nil { + t.Error("feat-b worktree should be removed after prune") + } + + // feat-a and feat-c should still exist + if _, err := os.Stat(wtAPath); os.IsNotExist(err) { + t.Error("feat-a worktree should NOT be removed") + } + if _, err := os.Stat(wtCPath); os.IsNotExist(err) { + t.Error("feat-c worktree should NOT be removed") + } +} + +// TestPrune_HistoryCleanup tests that pruning a worktree removes its history entry. +// +// Scenario: A worktree is created and recorded in history. After pruning, +// the history file should no longer contain the entry. +func TestPrune_HistoryCleanup(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpDir = resolvePath(t, tmpDir) + + repoPath := setupTestRepoWithBranches(t, tmpDir, "test-repo", []string{"feature"}) + wtPath := createTestWorktree(t, repoPath, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + histPath := filepath.Join(tmpDir, ".wt", "history.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry dir: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "test-repo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + // Record an access to the worktree so it appears in history + if err := history.RecordAccess(wtPath, "test-repo", "feature", histPath); err != nil { + t.Fatalf("failed to record history: %v", err) + } + + // Verify history entry exists before prune + hist, err := history.Load(histPath) + if err != nil { + t.Fatalf("failed to load history: %v", err) + } + if hist.FindByPath(wtPath) == nil { + t.Fatal("history entry should exist before prune") + } + + cfg := &config.Config{RegistryPath: regFile, HistoryPath: histPath} + ctx := testContextWithConfig(t, cfg, repoPath) + cmd := newPruneCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"feature", "-f"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("prune command failed: %v", err) + } + + // Verify worktree was removed + if _, err := os.Stat(wtPath); err == nil { + t.Error("worktree should be removed after prune") + } + + // Verify history entry was cleaned up + hist, err = history.Load(histPath) + if err != nil { + t.Fatalf("failed to load history after prune: %v", err) + } + if hist.FindByPath(wtPath) != nil { + t.Error("history entry should be removed after prune") + } +} + +// TestPrune_LocallyMerged_AutoPrune tests that a locally-merged branch is automatically +// pruned without the -f flag (no targeted prune, auto-prune mode). +// +// Scenario: A feature branch is merged into main locally. Running `wt prune` +// (without targeting a specific branch) should detect and remove it. +// Expected: The worktree for the merged branch is removed. +func TestPrune_LocallyMerged_AutoPrune(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpDir = resolvePath(t, tmpDir) + + repoPath := setupTestRepoWithBranches(t, tmpDir, "test-repo", []string{"feature"}) + wtPath := createTestWorktree(t, repoPath, "feature") + + // Add a commit on feature + addCommit(t, wtPath, "feature.txt", "feature work") + + // Merge feature into main (fast-forward or real merge) + if _, err := runGitCommand(repoPath, "merge", "feature"); err != nil { + t.Fatalf("failed to merge feature into main: %v", err) + } + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry dir: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "test-repo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, repoPath) + cmd := newPruneCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) // No target — auto-prune mode + + if err := cmd.Execute(); err != nil { + t.Fatalf("prune command failed: %v", err) + } + + // The locally-merged worktree should be removed + if _, err := os.Stat(wtPath); err == nil { + t.Error("locally-merged worktree should be auto-pruned") + } +} From 22672de06140a4bc82ff76a305edd81d8972a6bb Mon Sep 17 00:00:00 2001 From: ppn26 Date: Wed, 1 Apr 2026 12:15:29 +0200 Subject: [PATCH 4/6] test: add integration tests for list, note, label, diff, exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added 10 new integration tests covering previously untested code paths: - TestList_DefaultSortFromConfig: exercises cfg.DefaultSort config path - TestList_NonexistentLabelFilter: exercises error for unknown scope - TestNote_SpecialCharacters: roundtrip with special chars in note text - TestNote_SetAndGet: full set-then-get roundtrip on current branch - TestNote_Clear: set-then-clear-then-get flow - TestLabel_RegistryPersistence: verifies label survives registry reload - TestLabel_AddDuplicate: verifies idempotent label addition - TestDiff_NoChanges: diff on clean worktree with no commits ahead - TestExec_LabelScope: exec via label:branch scoped target - TestExec_BasicCommand: basic command execution in current worktree Coverage improvements: note_cmd.go newNoteSetCmd 11.8%→82.4%, newNoteGetCmd 50%→81.8%, newNoteClearCmd 12.5%→81.2%, getCurrentRepoBranch 0%→41.2%, list_cmd.go newListCmd 82.8%→84.5%. Unit count: 10 integration tests added. Co-Authored-By: Claude Opus 4.6 --- cmd/wt/diff_integration_test.go | 29 ++++++ cmd/wt/exec_integration_test.go | 92 +++++++++++++++++ cmd/wt/label_integration_test.go | 124 +++++++++++++++++++++++ cmd/wt/list_integration_test.go | 104 +++++++++++++++++++ cmd/wt/note_integration_test.go | 167 +++++++++++++++++++++++++++++++ 5 files changed, 516 insertions(+) diff --git a/cmd/wt/diff_integration_test.go b/cmd/wt/diff_integration_test.go index 8b512cf..c38009b 100644 --- a/cmd/wt/diff_integration_test.go +++ b/cmd/wt/diff_integration_test.go @@ -400,3 +400,32 @@ func TestDiff_ToolFlag(t *testing.T) { t.Fatalf("diff command with --tool failed: %v", err) } } + +// TestDiff_NoChanges tests diffing a clean worktree that has no commits ahead of origin. +// +// Scenario: User runs `wt diff --name-only` in a worktree that has no commits beyond origin/main +// Expected: Succeeds with no output (no changed files) +func TestDiff_NoChanges(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + // setupTestRepoWithOrigin creates a repo with a real origin remote and + // pushes main to it, so origin/main is up to date with main. + repoPath, _ := setupTestRepoWithOrigin(t, tmpDir, "myrepo") + + regFile := setupDiffRegistry(t, tmpDir, []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }) + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, repoPath) + + cmd := newDiffCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--name-only"}) + + // Should succeed — no changes means empty diff output + if err := cmd.Execute(); err != nil { + t.Fatalf("diff command on clean worktree failed: %v", err) + } +} diff --git a/cmd/wt/exec_integration_test.go b/cmd/wt/exec_integration_test.go index 8c077aa..fddce45 100644 --- a/cmd/wt/exec_integration_test.go +++ b/cmd/wt/exec_integration_test.go @@ -483,3 +483,95 @@ func TestExec_ByRepoScope(t *testing.T) { t.Errorf("expected file %q to be created in main worktree", testFile) } } + +// TestExec_LabelScope tests running a command in worktrees matched by a label scope. +// +// Scenario: Two repos are labeled "team-b", user runs `wt exec team-b:feature -- touch file` +// Expected: Command runs in both repos' feature worktrees +func TestExec_LabelScope(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repo1Path := setupTestRepo(t, tmpDir, "svc-x") + repo2Path := setupTestRepo(t, tmpDir, "svc-y") + + wt1Path := createTestWorktree(t, repo1Path, "feature") + wt2Path := createTestWorktree(t, repo2Path, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "svc-x", Path: repo1Path, Labels: []string{"team-b"}}, + {Name: "svc-y", Path: repo2Path, Labels: []string{"team-b"}}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, tmpDir) + + cmd := newExecCmd() + cmd.SetContext(ctx) + // "team-b" is a label (not a repo name), so label:branch resolution applies + cmd.SetArgs([]string{"team-b:feature", "--", "touch", "label-exec-file"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("exec command with label scope failed: %v", err) + } + + // Verify file was created in both feature worktrees + for _, wtPath := range []string{wt1Path, wt2Path} { + testFile := filepath.Join(wtPath, "label-exec-file") + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Errorf("expected file %q to be created in worktree %s", testFile, wtPath) + } + } +} + +// TestExec_BasicCommand tests running a simple command in the current worktree. +// +// Scenario: User runs `wt exec -- sh -c 'echo hello > output.txt'` from a worktree +// Expected: Command runs in the current directory and creates the output file +func TestExec_BasicCommand(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + outputFile := filepath.Join(repoPath, "basic-output.txt") + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, repoPath) + + cmd := newExecCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--", "sh", "-c", "echo hello > " + outputFile}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("exec command failed: %v", err) + } + + if _, err := os.Stat(outputFile); os.IsNotExist(err) { + t.Errorf("expected output file %q to be created", outputFile) + } +} diff --git a/cmd/wt/label_integration_test.go b/cmd/wt/label_integration_test.go index e2053c1..50e0cf9 100644 --- a/cmd/wt/label_integration_test.go +++ b/cmd/wt/label_integration_test.go @@ -696,3 +696,127 @@ func TestLabel_Clear_CurrentRepo(t *testing.T) { t.Errorf("expected 0 labels after clear, got %d: %v", len(repo.Labels), repo.Labels) } } + +// TestLabel_RegistryPersistence tests that labels survive a registry reload from disk. +// +// Scenario: User adds a label, then a new process loads the registry from disk +// Expected: Label is present in the reloaded registry +func TestLabel_RegistryPersistence(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath, Labels: []string{}}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, repoPath) + + cmd := newLabelCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"add", "persistent", "myrepo"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("label add command failed: %v", err) + } + + // Simulate a new process by reloading the registry from disk + reloaded, err := registry.Load(regFile) + if err != nil { + t.Fatalf("failed to reload registry from disk: %v", err) + } + + repo, err := reloaded.FindByName("myrepo") + if err != nil { + t.Fatalf("failed to find repo in reloaded registry: %v", err) + } + + found := false + for _, l := range repo.Labels { + if l == "persistent" { + found = true + break + } + } + if !found { + t.Errorf("label 'persistent' not found after registry reload, labels: %v", repo.Labels) + } +} + +// TestLabel_AddDuplicate tests that adding a duplicate label is idempotent. +// +// Scenario: User adds the same label twice to the same repo +// Expected: Label appears exactly once, no error +func TestLabel_AddDuplicate(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath, Labels: []string{}}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + + // Add the label once + ctx1 := testContextWithConfig(t, cfg, repoPath) + cmd1 := newLabelCmd() + cmd1.SetContext(ctx1) + cmd1.SetArgs([]string{"add", "team-a", "myrepo"}) + if err := cmd1.Execute(); err != nil { + t.Fatalf("first label add failed: %v", err) + } + + // Add the same label again + ctx2 := testContextWithConfig(t, cfg, repoPath) + cmd2 := newLabelCmd() + cmd2.SetContext(ctx2) + cmd2.SetArgs([]string{"add", "team-a", "myrepo"}) + if err := cmd2.Execute(); err != nil { + t.Fatalf("second label add failed: %v", err) + } + + // Reload and verify label appears exactly once + reloaded, err := registry.Load(regFile) + if err != nil { + t.Fatalf("failed to reload registry: %v", err) + } + + repo, err := reloaded.FindByName("myrepo") + if err != nil { + t.Fatalf("failed to find repo: %v", err) + } + + count := 0 + for _, l := range repo.Labels { + if l == "team-a" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 'team-a' label, got %d in %v", count, repo.Labels) + } +} diff --git a/cmd/wt/list_integration_test.go b/cmd/wt/list_integration_test.go index ef418eb..10312ae 100644 --- a/cmd/wt/list_integration_test.go +++ b/cmd/wt/list_integration_test.go @@ -622,3 +622,107 @@ func TestList_GlobalFromNonRepo(t *testing.T) { t.Errorf("expected output to contain 'feature', got %q", output) } } + +// TestList_DefaultSortFromConfig tests that default_sort in config is used when --sort is not set. +// +// Scenario: User sets default_sort = "branch" in config, runs `wt list` without --sort +// Expected: Worktrees are sorted alphabetically by branch name (from config) +func TestList_DefaultSortFromConfig(t *testing.T) { + t.Parallel() + tmpDir := resolvePath(t, t.TempDir()) + + repoPath := setupTestRepoWithBranches(t, tmpDir, "test-repo", []string{"gamma", "alpha", "beta"}) + createTestWorktree(t, repoPath, "gamma") + createTestWorktree(t, repoPath, "alpha") + createTestWorktree(t, repoPath, "beta") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "test-repo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + // Set default_sort = "branch" in config; no --sort flag will be passed + cfg := &config.Config{ + RegistryPath: regFile, + DefaultSort: "branch", + } + ctx, out := testContextWithOutput(t) + ctx = config.WithConfig(ctx, cfg) + ctx = config.WithWorkDir(ctx, repoPath) + + cmd := newListCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) // No --sort flag — config's default_sort should apply + + if err := cmd.Execute(); err != nil { + t.Fatalf("list command failed: %v", err) + } + + output := out.String() + // All branches should appear + for _, branch := range []string{"alpha", "beta", "gamma"} { + if !strings.Contains(output, branch) { + t.Errorf("expected output to contain %q, got %q", branch, output) + } + } + + // Verify alphabetical order: alpha < beta < gamma + alphaIdx := strings.Index(output, "alpha") + betaIdx := strings.Index(output, "beta") + gammaIdx := strings.Index(output, "gamma") + if alphaIdx > betaIdx || betaIdx > gammaIdx { + t.Errorf("expected alphabetical order (alpha < beta < gamma), got alpha=%d beta=%d gamma=%d", + alphaIdx, betaIdx, gammaIdx) + } +} + +// TestList_NonexistentLabelFilter tests filtering by a name that matches no repo and no label. +// +// Scenario: Registry has repos with labels, but the requested scope matches nothing +// Expected: Returns "no repo or label found" error +func TestList_NonexistentLabelFilter(t *testing.T) { + t.Parallel() + tmpDir := resolvePath(t, t.TempDir()) + + repoPath := setupTestRepo(t, tmpDir, "myrepo") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath, Labels: []string{"backend"}}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + ctx, _ := testContextWithOutput(t) + ctx = config.WithConfig(ctx, cfg) + ctx = config.WithWorkDir(ctx, tmpDir) + + cmd := newListCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"nonexistent-label"}) // Not a repo name, not a label + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for nonexistent scope, got nil") + } + if !strings.Contains(err.Error(), "no repo or label found") { + t.Errorf("expected 'no repo or label found' error, got %q", err.Error()) + } +} diff --git a/cmd/wt/note_integration_test.go b/cmd/wt/note_integration_test.go index 850cc10..9392edb 100644 --- a/cmd/wt/note_integration_test.go +++ b/cmd/wt/note_integration_test.go @@ -378,3 +378,170 @@ func TestNoteSet_LabelScope(t *testing.T) { } } } + +// TestNote_SpecialCharacters tests that notes with special characters round-trip correctly. +// +// Scenario: User sets a note containing special characters, then retrieves it +// Expected: Retrieved note matches what was set +func TestNote_SpecialCharacters(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + wtPath := createTestWorktree(t, repoPath, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + specialNote := "fix: handle edge-case with spaces & symbols!" + cfg := &config.Config{RegistryPath: regFile} + ctx := testContextWithConfig(t, cfg, wtPath) + + // Set note with special characters + setCmd := newNoteCmd() + setCmd.SetContext(ctx) + setCmd.SetArgs([]string{"set", specialNote}) + + if err := setCmd.Execute(); err != nil { + t.Fatalf("note set command failed: %v", err) + } + + // Get the note back via context with output + ctx2, out := testContextWithConfigAndOutput(t, cfg, wtPath) + getCmd := newNoteCmd() + getCmd.SetContext(ctx2) + getCmd.SetArgs([]string{"get"}) + + if err := getCmd.Execute(); err != nil { + t.Fatalf("note get command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if got != specialNote { + t.Errorf("expected note %q, got %q", specialNote, got) + } +} + +// TestNote_SetAndGet tests the full set-then-get roundtrip on the current branch. +// +// Scenario: User sets a note on the current worktree branch, then retrieves it +// Expected: Retrieved note matches the set value +func TestNote_SetAndGet(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + wtPath := createTestWorktree(t, repoPath, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + + // Set the note + setCtx := testContextWithConfig(t, cfg, wtPath) + setCmd := newNoteCmd() + setCmd.SetContext(setCtx) + setCmd.SetArgs([]string{"set", "in progress"}) + + if err := setCmd.Execute(); err != nil { + t.Fatalf("note set command failed: %v", err) + } + + // Get the note back + getCtx, out := testContextWithConfigAndOutput(t, cfg, wtPath) + getCmd := newNoteCmd() + getCmd.SetContext(getCtx) + getCmd.SetArgs([]string{"get"}) + + if err := getCmd.Execute(); err != nil { + t.Fatalf("note get command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if got != "in progress" { + t.Errorf("expected note 'in progress', got %q", got) + } +} + +// TestNote_Clear tests that clearing a note removes it from git config. +// +// Scenario: User sets a note then clears it, verifying the note is gone +// Expected: After clear, get returns empty output +func TestNote_Clear(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepo(t, tmpDir, "myrepo") + wtPath := createTestWorktree(t, repoPath, "feature") + + regFile := filepath.Join(tmpDir, ".wt", "repos.json") + if err := os.MkdirAll(filepath.Dir(regFile), 0755); err != nil { + t.Fatalf("failed to create registry directory: %v", err) + } + + reg := ®istry.Registry{ + Repos: []registry.Repo{ + {Name: "myrepo", Path: repoPath}, + }, + } + if err := reg.Save(regFile); err != nil { + t.Fatalf("failed to save registry: %v", err) + } + + cfg := &config.Config{RegistryPath: regFile} + + // Set the note first + setCtx := testContextWithConfig(t, cfg, wtPath) + setCmd := newNoteCmd() + setCmd.SetContext(setCtx) + setCmd.SetArgs([]string{"set", "review needed"}) + if err := setCmd.Execute(); err != nil { + t.Fatalf("note set command failed: %v", err) + } + + // Clear the note + clearCtx := testContextWithConfig(t, cfg, wtPath) + clearCmd := newNoteCmd() + clearCmd.SetContext(clearCtx) + clearCmd.SetArgs([]string{"clear"}) + if err := clearCmd.Execute(); err != nil { + t.Fatalf("note clear command failed: %v", err) + } + + // Get the note — should be empty + getCtx, out := testContextWithConfigAndOutput(t, cfg, wtPath) + getCmd := newNoteCmd() + getCmd.SetContext(getCtx) + getCmd.SetArgs([]string{"get"}) + if err := getCmd.Execute(); err != nil { + t.Fatalf("note get command failed: %v", err) + } + + got := strings.TrimSpace(out.String()) + if got != "" { + t.Errorf("expected empty output after clear, got %q", got) + } +} From 18756e0a56068db3ebfda09cc7b8f333fb5c22a1 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Wed, 1 Apr 2026 12:31:43 +0200 Subject: [PATCH 5/6] test: add git package tests for uncovered functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for RunGitCommand, FetchBranch, FetchBranchFromRemote, PushBranch, GetWorktreeBranches, and DeleteLocalBranch — all previously at 0% coverage. 14 new test cases covering success paths, error paths, and edge cases (force delete, no remote, non-git dir, detached HEAD). Co-Authored-By: Claude Opus 4.6 --- internal/git/repo_test.go | 321 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) diff --git a/internal/git/repo_test.go b/internal/git/repo_test.go index f85e8b4..17049ea 100644 --- a/internal/git/repo_test.go +++ b/internal/git/repo_test.go @@ -1169,3 +1169,324 @@ func TestGetMergedBranches(t *testing.T) { } }) } + +func TestRunGitCommand(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := RunGitCommand(ctx, repoPath, "status"); err != nil { + t.Fatalf("RunGitCommand(status) failed: %v", err) + } + }) + + t.Run("error on invalid command", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := RunGitCommand(ctx, repoPath, "nonexistent-subcommand"); err == nil { + t.Error("expected error for invalid git subcommand") + } + }) +} + +func TestFetchBranch(t *testing.T) { + t.Parallel() + + t.Run("fetch existing branch from origin", func(t *testing.T) { + t.Parallel() + repoPath, originPath := setupTestRepoWithOrigin(t) + ctx := context.Background() + + // Push a new branch to origin from the clone + if err := runGit(ctx, repoPath, "checkout", "-b", "fetch-test"); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "fetch.txt"), []byte("fetch\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if err := runGit(ctx, repoPath, "add", "fetch.txt"); err != nil { + t.Fatalf("failed to add: %v", err) + } + if err := runGit(ctx, repoPath, "commit", "-m", "Add fetch file"); err != nil { + t.Fatalf("failed to commit: %v", err) + } + if err := runGit(ctx, repoPath, "push", "origin", "fetch-test"); err != nil { + t.Fatalf("failed to push to origin: %v", err) + } + + // Create a second clone that will fetch the branch + tmpDir := resolveTempDir(t) + clonePath := filepath.Join(tmpDir, "clone2") + if err := runGit(ctx, "", "clone", originPath, clonePath); err != nil { + t.Fatalf("failed to create second clone: %v", err) + } + configureTestRepo(t, clonePath) + + if err := FetchBranch(ctx, clonePath, "fetch-test"); err != nil { + t.Fatalf("FetchBranch failed: %v", err) + } + + // Verify the remote tracking branch now exists + if !RemoteBranchExists(ctx, clonePath, "fetch-test") { + t.Error("expected origin/fetch-test to exist after fetch") + } + }) + + t.Run("error on nonexistent remote branch", func(t *testing.T) { + t.Parallel() + repoPath, _ := setupTestRepoWithOrigin(t) + ctx := context.Background() + + if err := FetchBranch(ctx, repoPath, "does-not-exist"); err == nil { + t.Error("expected error fetching nonexistent branch") + } + }) +} + +func TestFetchBranchFromRemote(t *testing.T) { + t.Parallel() + + t.Run("fetch from named remote", func(t *testing.T) { + t.Parallel() + repoPath, originPath := setupTestRepoWithOrigin(t) + ctx := context.Background() + + // Create a second clone as an upstream remote + tmpDir := resolveTempDir(t) + upstreamPath := filepath.Join(tmpDir, "upstream") + if err := runGit(ctx, "", "clone", "--bare", originPath, upstreamPath); err != nil { + t.Fatalf("failed to create upstream: %v", err) + } + + // Add upstream as a remote + if err := runGit(ctx, repoPath, "remote", "add", "upstream", upstreamPath); err != nil { + t.Fatalf("failed to add upstream remote: %v", err) + } + + if err := FetchBranchFromRemote(ctx, repoPath, "upstream", "main"); err != nil { + t.Fatalf("FetchBranchFromRemote failed: %v", err) + } + + // Verify upstream/main tracking ref exists + if !RefExists(ctx, repoPath, "refs/remotes/upstream/main") { + t.Error("expected refs/remotes/upstream/main to exist after fetch") + } + }) + + t.Run("error on nonexistent remote", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := FetchBranchFromRemote(ctx, repoPath, "no-such-remote", "main"); err == nil { + t.Error("expected error fetching from nonexistent remote") + } + }) +} + +func TestPushBranch(t *testing.T) { + t.Parallel() + + t.Run("push new branch to origin", func(t *testing.T) { + t.Parallel() + repoPath, _ := setupTestRepoWithOrigin(t) + ctx := context.Background() + + if err := runGit(ctx, repoPath, "checkout", "-b", "push-test"); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "push.txt"), []byte("push\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if err := runGit(ctx, repoPath, "add", "push.txt"); err != nil { + t.Fatalf("failed to add: %v", err) + } + if err := runGit(ctx, repoPath, "commit", "-m", "Add push file"); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + if err := PushBranch(ctx, repoPath, "push-test"); err != nil { + t.Fatalf("PushBranch failed: %v", err) + } + + // Verify remote tracking branch now exists + if !RemoteBranchExists(ctx, repoPath, "push-test") { + t.Error("expected origin/push-test to exist after push") + } + }) + + t.Run("error when no remote configured", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := PushBranch(ctx, repoPath, "main"); err == nil { + t.Error("expected error pushing without remote") + } + }) +} + +func TestGetWorktreeBranches(t *testing.T) { + t.Parallel() + + t.Run("single worktree (main only)", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + branches := GetWorktreeBranches(ctx, repoPath) + if !branches["main"] { + t.Errorf("expected main in worktree branches, got %v", branches) + } + if len(branches) != 1 { + t.Errorf("expected 1 worktree branch, got %d: %v", len(branches), branches) + } + }) + + t.Run("includes linked worktree branches", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + tmpDir := filepath.Dir(repoPath) + ctx := context.Background() + + wt1 := filepath.Join(tmpDir, "wt-branch-a") + wt2 := filepath.Join(tmpDir, "wt-branch-b") + if err := runGit(ctx, repoPath, "worktree", "add", "-b", "branch-a", wt1); err != nil { + t.Fatalf("failed to create worktree branch-a: %v", err) + } + if err := runGit(ctx, repoPath, "worktree", "add", "-b", "branch-b", wt2); err != nil { + t.Fatalf("failed to create worktree branch-b: %v", err) + } + + branches := GetWorktreeBranches(ctx, repoPath) + for _, want := range []string{"main", "branch-a", "branch-b"} { + if !branches[want] { + t.Errorf("expected %q in worktree branches, got %v", want, branches) + } + } + }) + + t.Run("non-git dir returns empty map", func(t *testing.T) { + t.Parallel() + nonGitDir := t.TempDir() + ctx := context.Background() + + branches := GetWorktreeBranches(ctx, nonGitDir) + if len(branches) != 0 { + t.Errorf("expected empty map for non-git dir, got %v", branches) + } + }) +} + +func TestDeleteLocalBranch(t *testing.T) { + t.Parallel() + + t.Run("delete merged branch without force", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + // Create a branch that is merged into main (fast-forward) + if err := runGit(ctx, repoPath, "checkout", "-b", "to-delete"); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "delete.txt"), []byte("delete\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if err := runGit(ctx, repoPath, "add", "delete.txt"); err != nil { + t.Fatalf("failed to add: %v", err) + } + if err := runGit(ctx, repoPath, "commit", "-m", "Delete branch commit"); err != nil { + t.Fatalf("failed to commit: %v", err) + } + if err := runGit(ctx, repoPath, "checkout", "main"); err != nil { + t.Fatalf("failed to checkout main: %v", err) + } + if err := runGit(ctx, repoPath, "merge", "--ff-only", "to-delete"); err != nil { + t.Fatalf("failed to merge: %v", err) + } + + if err := DeleteLocalBranch(ctx, repoPath, "to-delete", false); err != nil { + t.Fatalf("DeleteLocalBranch failed: %v", err) + } + + if LocalBranchExists(ctx, repoPath, "to-delete") { + t.Error("expected branch to-delete to be deleted") + } + }) + + t.Run("delete unmerged branch with force", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := runGit(ctx, repoPath, "checkout", "-b", "force-delete"); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "force.txt"), []byte("force\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if err := runGit(ctx, repoPath, "add", "force.txt"); err != nil { + t.Fatalf("failed to add: %v", err) + } + if err := runGit(ctx, repoPath, "commit", "-m", "Unmerged commit"); err != nil { + t.Fatalf("failed to commit: %v", err) + } + if err := runGit(ctx, repoPath, "checkout", "main"); err != nil { + t.Fatalf("failed to checkout main: %v", err) + } + + if err := DeleteLocalBranch(ctx, repoPath, "force-delete", true); err != nil { + t.Fatalf("DeleteLocalBranch with force failed: %v", err) + } + + if LocalBranchExists(ctx, repoPath, "force-delete") { + t.Error("expected branch force-delete to be deleted") + } + }) + + t.Run("error deleting unmerged branch without force", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := runGit(ctx, repoPath, "checkout", "-b", "unmerged-no-force"); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if err := os.WriteFile(filepath.Join(repoPath, "unmerged.txt"), []byte("unmerged\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if err := runGit(ctx, repoPath, "add", "unmerged.txt"); err != nil { + t.Fatalf("failed to add: %v", err) + } + if err := runGit(ctx, repoPath, "commit", "-m", "Unmerged"); err != nil { + t.Fatalf("failed to commit: %v", err) + } + if err := runGit(ctx, repoPath, "checkout", "main"); err != nil { + t.Fatalf("failed to checkout main: %v", err) + } + + if err := DeleteLocalBranch(ctx, repoPath, "unmerged-no-force", false); err == nil { + t.Error("expected error deleting unmerged branch without force") + } + + if !LocalBranchExists(ctx, repoPath, "unmerged-no-force") { + t.Error("branch should still exist after failed delete") + } + }) + + t.Run("error deleting nonexistent branch", func(t *testing.T) { + t.Parallel() + repoPath := setupTestRepo(t) + ctx := context.Background() + + if err := DeleteLocalBranch(ctx, repoPath, "no-such-branch", false); err == nil { + t.Error("expected error deleting nonexistent branch") + } + }) +} From 9c31db6bcfaccfc3002c052652a0fe6bbfad9078 Mon Sep 17 00:00:00 2001 From: ppn26 Date: Wed, 1 Apr 2026 13:02:15 +0200 Subject: [PATCH 6/6] test: add findWorktreeForBranch edge case tests Cover the branch-not-found and invalid-repo error paths for findWorktreeForBranch, ensuring full coverage of the function introduced in ae4ac79 (open existing worktree on duplicate pr checkout). Co-Authored-By: Claude Opus 4.6 --- cmd/wt/pr_integration_test.go | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/cmd/wt/pr_integration_test.go b/cmd/wt/pr_integration_test.go index 78c9d00..f8c149d 100644 --- a/cmd/wt/pr_integration_test.go +++ b/cmd/wt/pr_integration_test.go @@ -613,3 +613,44 @@ func TestPrCheckout_AlreadyCheckedOut(t *testing.T) { t.Fatalf("worktree should still exist at %s", wtPath) } } + +// TestFindWorktreeForBranch_NotFound tests that findWorktreeForBranch returns +// false when the branch has no worktree checked out. +// +// Scenario: Repo has branch "feature" but no worktree for it +// Expected: Returns ("", false) +func TestFindWorktreeForBranch_NotFound(t *testing.T) { + t.Parallel() + + tmpDir := resolvePath(t, t.TempDir()) + repoPath := setupTestRepoWithBranches(t, tmpDir, "myrepo", []string{"feature"}) + + ctx := testContext(t) + + path, found := findWorktreeForBranch(ctx, repoPath, "feature") + if found { + t.Errorf("expected branch not found in worktrees, got path %q", path) + } + if path != "" { + t.Errorf("expected empty path, got %q", path) + } +} + +// TestFindWorktreeForBranch_InvalidRepo tests that findWorktreeForBranch +// returns false when the repo path is invalid (ListWorktreesFromRepo error path). +// +// Scenario: Repo path does not exist +// Expected: Returns ("", false) without panicking +func TestFindWorktreeForBranch_InvalidRepo(t *testing.T) { + t.Parallel() + + ctx := testContext(t) + + path, found := findWorktreeForBranch(ctx, "/nonexistent/path", "feature") + if found { + t.Errorf("expected not found for invalid repo, got path %q", path) + } + if path != "" { + t.Errorf("expected empty path, got %q", path) + } +}