Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
120 changes: 120 additions & 0 deletions internal/hooktrigger/trigger_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
52 changes: 52 additions & 0 deletions internal/preserve/preserve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading