diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ec3efaf..9029d33 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1162,3 +1162,102 @@ func TestValidateHookTriggers(t *testing.T) { }) } } + +func TestParseHooksConfig_NonTableValues(t *testing.T) { + t.Parallel() + + // Non-table values in hooks map should be silently ignored + raw := map[string]any{ + "valid": map[string]any{ + "command": "echo valid", + }, + "string-value": "not a table", + "number-value": 42, + "bool-value": true, + } + + result := parseHooksConfig(raw) + if len(result.Hooks) != 1 { + t.Errorf("len(Hooks) = %d, want 1 (only valid table entry)", len(result.Hooks)) + } + if _, ok := result.Hooks["valid"]; !ok { + t.Error("missing 'valid' hook") + } +} + +func TestParseHooksConfig_EmptyCommand(t *testing.T) { + t.Parallel() + + raw := map[string]any{ + "empty": map[string]any{ + "description": "no command field", + }, + } + + result := parseHooksConfig(raw) + hook := result.Hooks["empty"] + if hook.Command != "" { + t.Errorf("Command = %q, want empty", hook.Command) + } + if hook.Description != "no command field" { + t.Errorf("Description = %q, want %q", hook.Description, "no command field") + } +} + +func TestMatchPattern_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + spec string + want bool + }{ + {"empty pattern empty spec", "", "", true}, + {"single char wildcard", "*", "", true}, + {"single char wildcard nonempty", "*", "a", true}, + {"prefix only slash", "org/", "org/", true}, + {"suffix match exact", "*/repo", "repo", false}, + {"prefix star", "*repo", "myrepo", true}, + {"prefix star slash", "*/", "org/", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := matchPattern(tt.pattern, tt.spec) + if got != tt.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.pattern, tt.spec, got, tt.want) + } + }) + } +} + +func TestIsEnabled_Default(t *testing.T) { + t.Parallel() + + h := Hook{Command: "echo test"} + if !h.IsEnabled() { + t.Error("IsEnabled() = false for nil Enabled, want true") + } +} + +func TestGetForgeTypeForRepo_EmptyRules(t *testing.T) { + t.Parallel() + + cfg := ForgeConfig{Default: "github"} + got := cfg.GetForgeTypeForRepo("any/repo") + if got != "github" { + t.Errorf("GetForgeTypeForRepo = %q, want %q", got, "github") + } +} + +func TestGetUserForRepo_EmptyRules(t *testing.T) { + t.Parallel() + + cfg := ForgeConfig{Default: "github"} + got := cfg.GetUserForRepo("any/repo") + if got != "" { + t.Errorf("GetUserForRepo = %q, want empty", got) + } +} diff --git a/internal/hooktrigger/trigger_test.go b/internal/hooktrigger/trigger_test.go new file mode 100644 index 0000000..f8525df --- /dev/null +++ b/internal/hooktrigger/trigger_test.go @@ -0,0 +1,120 @@ +package hooktrigger + +import ( + "strings" + "testing" +) + +func TestParseTrigger(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want ParsedTrigger + wantErr string + }{ + // Single segment — defaults to phase=after + {"bare checkout", "checkout", ParsedTrigger{Phase: "after", Trigger: "checkout"}, ""}, + {"bare prune", "prune", ParsedTrigger{Phase: "after", Trigger: "prune"}, ""}, + {"bare merge", "merge", ParsedTrigger{Phase: "after", Trigger: "merge"}, ""}, + {"bare all", "all", ParsedTrigger{Phase: "after", Trigger: "all"}, ""}, + + // Two segments: timing:trigger + {"before checkout", "before:checkout", ParsedTrigger{Phase: "before", Trigger: "checkout"}, ""}, + {"after prune", "after:prune", ParsedTrigger{Phase: "after", Trigger: "prune"}, ""}, + + // Two segments: trigger:subtype + {"checkout create", "checkout:create", ParsedTrigger{Phase: "after", Trigger: "checkout", Subtype: "create"}, ""}, + {"checkout open", "checkout:open", ParsedTrigger{Phase: "after", Trigger: "checkout", Subtype: "open"}, ""}, + {"checkout pr", "checkout:pr", ParsedTrigger{Phase: "after", Trigger: "checkout", Subtype: "pr"}, ""}, + + // Three segments: timing:trigger:subtype + {"before checkout create", "before:checkout:create", ParsedTrigger{Phase: "before", Trigger: "checkout", Subtype: "create"}, ""}, + {"after checkout pr", "after:checkout:pr", ParsedTrigger{Phase: "after", Trigger: "checkout", Subtype: "pr"}, ""}, + + // Error: empty + {"empty string", "", ParsedTrigger{}, "empty trigger value"}, + + // Error: too many segments + {"four segments", "a:b:c:d", ParsedTrigger{}, "too many segments"}, + + // Error: empty segments in middle + {"empty trigger", "before:", ParsedTrigger{}, "empty trigger"}, + {"empty subtype", "checkout:", ParsedTrigger{}, "empty trigger"}, + {"empty trigger three", "before::create", ParsedTrigger{}, "empty trigger"}, + + // Error: unknown timing + {"unknown timing", "sometimes:checkout", ParsedTrigger{}, "unknown timing"}, + {"unknown timing three", "sometimes:checkout:create", ParsedTrigger{}, "unknown timing"}, + + // Error: removed trigger + {"removed cd", "cd", ParsedTrigger{}, "no longer a valid trigger"}, + {"removed cd with subtype", "cd:create", ParsedTrigger{}, "no longer a valid trigger"}, + + // Error: invalid trigger name + {"invalid trigger", "deploy", ParsedTrigger{}, "not a valid trigger"}, + + // Error: subtypes on non-checkout triggers + {"prune with subtype", "prune:create", ParsedTrigger{}, "does not support subtypes"}, + {"merge with subtype", "merge:open", ParsedTrigger{}, "does not support subtypes"}, + {"all with subtype", "all:create", ParsedTrigger{}, "does not support subtypes"}, + + // Error: invalid subtype for checkout + {"invalid checkout subtype", "checkout:deploy", ParsedTrigger{}, "unknown subtype"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseTrigger(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("ParseTrigger(%q) = %v, want error containing %q", tt.input, got, tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ParseTrigger(%q) error = %q, want containing %q", tt.input, err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ParseTrigger(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("ParseTrigger(%q) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestMatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parsed ParsedTrigger + trigger string + subtype string + want bool + }{ + {"all matches checkout", ParsedTrigger{Trigger: "all"}, "checkout", "create", true}, + {"all matches prune", ParsedTrigger{Trigger: "all"}, "prune", "", true}, + {"checkout matches checkout", ParsedTrigger{Trigger: "checkout"}, "checkout", "create", true}, + {"checkout no subtype matches any", ParsedTrigger{Trigger: "checkout"}, "checkout", "pr", true}, + {"checkout:create matches create", ParsedTrigger{Trigger: "checkout", Subtype: "create"}, "checkout", "create", true}, + {"checkout:create no match open", ParsedTrigger{Trigger: "checkout", Subtype: "create"}, "checkout", "open", false}, + {"checkout no match prune", ParsedTrigger{Trigger: "checkout"}, "prune", "", false}, + {"prune matches prune", ParsedTrigger{Trigger: "prune"}, "prune", "", true}, + {"prune no match checkout", ParsedTrigger{Trigger: "prune"}, "checkout", "create", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.parsed.Matches(tt.trigger, tt.subtype) + if got != tt.want { + t.Errorf("Matches(%q, %q) = %v, want %v", tt.trigger, tt.subtype, got, tt.want) + } + }) + } +} diff --git a/internal/preserve/preserve_test.go b/internal/preserve/preserve_test.go index c539355..813f129 100644 --- a/internal/preserve/preserve_test.go +++ b/internal/preserve/preserve_test.go @@ -306,6 +306,29 @@ func TestCopyFile(t *testing.T) { t.Error("dst should not exist when source is a symlink") } }) + + t.Run("returns error when dst parent path is a file", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + src := filepath.Join(tmpDir, "src.txt") + if err := os.WriteFile(src, []byte("hello\n"), 0644); err != nil { + t.Fatalf("setup: write src failed: %v", err) + } + + // Create a regular file where the directory should be — MkdirAll will fail. + blocker := filepath.Join(tmpDir, "notadir") + if err := os.WriteFile(blocker, []byte("block\n"), 0644); err != nil { + t.Fatalf("setup: write blocker failed: %v", err) + } + + dst := filepath.Join(blocker, "dst.txt") + + _, err := CopyFile(src, dst) + if err == nil { + t.Fatal("CopyFile() should return error when dst parent is a file") + } + }) } func resolveTempDir(t *testing.T) string { @@ -367,6 +390,35 @@ func initBareRepoWithWorktree(t *testing.T, baseDir string) (string, string) { return repoDir, mainWT } +// TestFindSourceWorktree_InvalidGitDir verifies that FindSourceWorktree returns +// an error when git.ListWorktreesFromRepo fails (non-existent git directory). +func TestFindSourceWorktree_InvalidGitDir(t *testing.T) { + t.Parallel() + + ctx := testContext() + tmpDir := resolveTempDir(t) + fakeGitDir := filepath.Join(tmpDir, "does-not-exist.git") + + _, err := FindSourceWorktree(ctx, fakeGitDir, filepath.Join(tmpDir, "some-worktree")) + if err == nil { + t.Error("FindSourceWorktree() expected error for invalid git dir, got nil") + } +} + +// TestFindIgnoredFiles_NonGitDir verifies that FindIgnoredFiles returns an +// error when the directory is not a git repository. +func TestFindIgnoredFiles_NonGitDir(t *testing.T) { + t.Parallel() + + ctx := testContext() + tmpDir := resolveTempDir(t) + // tmpDir is not a git repo, so git ls-files should fail. + _, err := FindIgnoredFiles(ctx, tmpDir) + if err == nil { + t.Error("FindIgnoredFiles() expected error for non-git directory, got nil") + } +} + func TestFindSourceWorktree(t *testing.T) { t.Parallel() diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index c925549..480919c 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -749,3 +749,260 @@ func TestRepoString(t *testing.T) { t.Errorf("String() = %q, want 'myrepo (backend, api)'", got) } } + +// TestLoad_DefaultPath exercises the path == "" branch in Load, which calls +// registryPath() → fs.WtDir() → os.UserHomeDir(). When no registry file +// exists at the default location the function must return an empty registry. +func TestLoad_DefaultPath(t *testing.T) { + t.Parallel() + + // We can't change $HOME safely in a parallel test, so we just verify + // that Load("") doesn't panic and returns either a registry or a + // meaningful error (UserHomeDir could theoretically fail in CI). + reg, err := Load("") + if err != nil { + // Acceptable: home dir not available in some environments. + t.Logf("Load(\"\") returned error (acceptable in CI): %v", err) + return + } + if reg == nil { + t.Error("Load(\"\") returned nil registry without error") + } +} + +// TestSave_DefaultPath exercises the path == "" branch in Save, which calls +// registryPath() → fs.WtDir() → os.UserHomeDir(). +func TestSave_DefaultPath(t *testing.T) { + t.Parallel() + + reg := &Registry{Repos: []Repo{}} + err := reg.Save("") + if err != nil { + // Acceptable: home dir not available in some environments. + t.Logf("Save(\"\") returned error (acceptable in CI): %v", err) + } + // No assertion needed beyond "does not panic"; the path branch is exercised. +} + +// TestSave_CreatesParentDir verifies that Save creates intermediate directories +// that don't exist yet. +func TestSave_CreatesParentDir(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + // Use a deeply nested path that doesn't exist yet. + regPath := filepath.Join(tmpDir, "a", "b", "c", "repos.json") + + reg := &Registry{ + Repos: []Repo{ + {Name: "test", Path: "/tmp/test"}, + }, + } + + if err := reg.Save(regPath); err != nil { + t.Fatalf("Save() failed: %v", err) + } + + if _, err := os.Stat(regPath); os.IsNotExist(err) { + t.Error("registry file was not created in nested directory") + } +} + +// TestSave_WritesValidJSON verifies that Save produces JSON that Load can +// round-trip correctly. +func TestSave_WritesValidJSON(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + regPath := filepath.Join(tmpDir, "repos.json") + + reg := &Registry{ + Repos: []Repo{ + {Name: "alpha", Path: "/tmp/alpha", Labels: []string{"backend"}}, + {Name: "beta", Path: "/tmp/beta"}, + }, + } + + if err := reg.Save(regPath); err != nil { + t.Fatalf("Save() failed: %v", err) + } + + loaded, err := Load(regPath) + if err != nil { + t.Fatalf("Load() after Save() failed: %v", err) + } + + if len(loaded.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(loaded.Repos)) + } + + r, err := loaded.FindByName("alpha") + if err != nil { + t.Fatalf("FindByName(alpha) failed: %v", err) + } + if !r.HasLabel("backend") { + t.Error("loaded repo missing label 'backend'") + } +} + +// TestLoad_MissingFile verifies that Load returns an empty registry (no error) +// when the file does not exist. +func TestLoad_MissingFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "does-not-exist.json") + + reg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if len(reg.Repos) != 0 { + t.Errorf("expected 0 repos for missing file, got %d", len(reg.Repos)) + } +} + +// TestLoad_InvalidJSON verifies that Load returns an error for malformed JSON. +func TestLoad_InvalidJSON(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "bad.json") + + if err := os.WriteFile(path, []byte("{not valid json"), 0644); err != nil { + t.Fatalf("setup: write failed: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Error("Load() expected error for invalid JSON, got nil") + } +} + +// TestRemoveLabel_NonexistentLabel verifies that RemoveLabel is a no-op (no +// error) when the label doesn't exist on the repo. +func TestRemoveLabel_NonexistentLabel(t *testing.T) { + t.Parallel() + + reg := &Registry{ + Repos: []Repo{ + {Name: "foo", Path: "/tmp/foo", Labels: []string{"backend"}}, + }, + } + + // Remove a label that was never added — should succeed silently. + if err := reg.RemoveLabel("foo", "nonexistent"); err != nil { + t.Errorf("RemoveLabel(nonexistent) error = %v, want nil", err) + } + + // Existing label should still be present. + repo, err := reg.FindByName("foo") + if err != nil { + t.Fatalf("FindByName failed: %v", err) + } + if !repo.HasLabel("backend") { + t.Error("existing label 'backend' was unexpectedly removed") + } +} + +// TestRemoveLabel_NonexistentRepo verifies that RemoveLabel returns an error +// when the repo doesn't exist. +func TestRemoveLabel_NonexistentRepo(t *testing.T) { + t.Parallel() + + reg := &Registry{Repos: []Repo{}} + + if err := reg.RemoveLabel("nonexistent", "label"); err == nil { + t.Error("RemoveLabel() on non-existent repo expected error, got nil") + } +} + +// TestClearLabels_EmptyLabels verifies that ClearLabels is a no-op when the +// repo already has no labels (nil slice). +func TestClearLabels_EmptyLabels(t *testing.T) { + t.Parallel() + + reg := &Registry{ + Repos: []Repo{ + {Name: "nolabels", Path: "/tmp/nolabels"}, + }, + } + + if err := reg.ClearLabels("nolabels"); err != nil { + t.Errorf("ClearLabels() on repo with no labels error = %v, want nil", err) + } + + repo, err := reg.FindByName("nolabels") + if err != nil { + t.Fatalf("FindByName failed: %v", err) + } + if len(repo.Labels) != 0 { + t.Errorf("expected 0 labels after ClearLabels, got %d", len(repo.Labels)) + } +} + +// TestClearLabels_NonexistentRepo verifies that ClearLabels returns an error +// when the named repo doesn't exist. +func TestClearLabels_NonexistentRepo(t *testing.T) { + t.Parallel() + + reg := &Registry{Repos: []Repo{}} + + if err := reg.ClearLabels("nonexistent"); err == nil { + t.Error("ClearLabels() on non-existent repo expected error, got nil") + } +} + +// TestPathExists_NonexistentPath verifies that PathExists returns false (no +// error) for a path that doesn't exist on disk. +func TestPathExists_NonexistentPath(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + repo := &Repo{Name: "gone", Path: filepath.Join(tmpDir, "does-not-exist")} + + exists, err := repo.PathExists() + if err != nil { + t.Fatalf("PathExists() error = %v, want nil", err) + } + if exists { + t.Error("PathExists() = true for non-existent path, want false") + } +} + +// TestAddLabel_NonexistentRepo verifies that AddLabel returns an error when +// the named repo doesn't exist. +func TestAddLabel_NonexistentRepo(t *testing.T) { + t.Parallel() + + reg := &Registry{Repos: []Repo{}} + + if err := reg.AddLabel("nonexistent", "backend"); err == nil { + t.Error("AddLabel() on non-existent repo expected error, got nil") + } +} + +// TestResolveAsPath verifies that resolveAsPath returns a canonical absolute +// path for a valid relative or absolute input. +func TestResolveAsPath(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(tmpDir) + if err != nil { + t.Fatalf("EvalSymlinks failed: %v", err) + } + + // Absolute path — should be canonicalized (symlinks resolved on macOS). + got := resolveAsPath(resolved) + if got != resolved { + t.Errorf("resolveAsPath(%q) = %q, want %q", resolved, got, resolved) + } + + // Relative path — should be converted to absolute. + // Use "." which always resolves to the current working directory. + got2 := resolveAsPath(".") + if got2 == "." { + t.Error("resolveAsPath('.') should return absolute path, got '.'") + } +} diff --git a/internal/ui/progress/progress_bar_test.go b/internal/ui/progress/progress_bar_test.go index a50573c..f46c5f8 100644 --- a/internal/ui/progress/progress_bar_test.go +++ b/internal/ui/progress/progress_bar_test.go @@ -132,3 +132,68 @@ func TestProgressBar_Total(t *testing.T) { } } } + +func TestProgressBarModel_Init(t *testing.T) { + t.Parallel() + + m := newTestModel(100, "Starting...") + cmd := m.Init() + + // Init should return a command (the waitForUpdate listener) + if cmd == nil { + t.Fatal("Init() returned nil cmd, want non-nil") + } +} + +func TestProgressBarModel_Update_UnknownMsg(t *testing.T) { + t.Parallel() + + m := newTestModel(100, "Working...") + m.current = 50 + + // Unknown message type should be forwarded to progress model + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + um := updated.(progressBarModel) + + // Current and message should be unchanged + if um.current != 50 { + t.Errorf("current = %d, want 50", um.current) + } + if um.message != "Working..." { + t.Errorf("message = %q, want %q", um.message, "Working...") + } +} + +func TestProgressBarModel_View_FullProgress(t *testing.T) { + t.Parallel() + + m := newTestModel(100, "Done") + m.current = 100 + // Should render 100% without panic + view := m.View() + if view.Content == "" { + t.Error("View() returned empty content for 100% progress") + } +} + +func TestProgressBar_SetProgress_UpdatesBeforeStart(t *testing.T) { + t.Parallel() + + pb := NewProgressBar(10, "Test") + pb.SetProgress(3, "Step 3") + pb.SetProgress(7, "Step 7") + // Should not panic, values updated internally + // Verify total is unchanged + if pb.Total() != 10 { + t.Errorf("Total() = %d, want 10", pb.Total()) + } +} + +func TestProgressBar_DoubleStop(t *testing.T) { + t.Parallel() + + pb := NewProgressBar(10, "Test") + // Double stop without start should not panic + pb.Stop() + pb.Stop() +}