diff --git a/internal/git/operations_test.go b/internal/git/operations_test.go new file mode 100644 index 0000000..72bd65e --- /dev/null +++ b/internal/git/operations_test.go @@ -0,0 +1,133 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/dfanso/commit-msg/pkg/types" +) + +func TestIsRepository(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git executable not available") + } + + t.Run("returns true for initialized repo", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + cmd := exec.Command("git", "init", dir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to init git repo: %v: %s", err, string(output)) + } + + if got := IsRepository(dir); !got { + t.Fatalf("IsRepository(%q) = false, want true", dir) + } + }) + + t.Run("returns false for non repo", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if got := IsRepository(dir); got { + t.Fatalf("IsRepository(%q) = true, want false", dir) + } + }) + + t.Run("returns false for missing path", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + missing := filepath.Join(dir, "does-not-exist") + if got := IsRepository(missing); got { + t.Fatalf("IsRepository(%q) = true, want false", missing) + } + }) +} + +func TestGetChangesIncludesSections(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration-style test in short mode") + } + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git executable not available") + } + + dir := t.TempDir() + + runGit(t, dir, "init") + runGit(t, dir, "config", "user.name", "Test User") + runGit(t, dir, "config", "user.email", "test@example.com") + + tracked := filepath.Join(dir, "tracked.txt") + if err := os.WriteFile(tracked, []byte("first version\n"), 0o644); err != nil { + t.Fatalf("failed to write tracked file: %v", err) + } + runGit(t, dir, "add", "tracked.txt") + runGit(t, dir, "commit", "-m", "initial commit") + + if err := os.WriteFile(tracked, []byte("updated version\n"), 0o644); err != nil { + t.Fatalf("failed to modify tracked file: %v", err) + } + + staged := filepath.Join(dir, "staged.txt") + if err := os.WriteFile(staged, []byte("staged content\n"), 0o644); err != nil { + t.Fatalf("failed to write staged file: %v", err) + } + runGit(t, dir, "add", "staged.txt") + + untracked := filepath.Join(dir, "new.txt") + if err := os.WriteFile(untracked, []byte("brand new file\n"), 0o644); err != nil { + t.Fatalf("failed to write untracked file: %v", err) + } + + output, err := GetChanges(&types.RepoConfig{Path: dir}) + if err != nil { + t.Fatalf("GetChanges returned error: %v", err) + } + + fragments := []string{ + "Unstaged changes:", + "Staged changes:", + "Untracked files:", + "Content of new file new.txt:", + "Recent commits for context:", + "brand new file", + } + + for _, fragment := range fragments { + if !strings.Contains(output, fragment) { + t.Fatalf("output missing fragment %q\noutput: %s", fragment, output) + } + } +} + +func TestGetChangesErrorsOutsideRepo(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + if _, err := GetChanges(&types.RepoConfig{Path: dir}); err == nil { + t.Fatal("expected error for directory without git repository") + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmdArgs := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", cmdArgs...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, output) + } +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..b3f3308 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,114 @@ +package utils + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestNormalizePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {name: "windows style", input: "foo\\bar\\", expected: "foo/bar"}, + {name: "already normalized", input: "foo/bar", expected: "foo/bar"}, + {name: "no trailing slash", input: "foo", expected: "foo"}, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := NormalizePath(tc.input); got != tc.expected { + t.Fatalf("NormalizePath(%q) = %q, want %q", tc.input, got, tc.expected) + } + }) + } +} + +func TestIsTextFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filename string + want bool + }{ + {name: "go source", filename: "main.go", want: true}, + {name: "markdown upper", filename: "README.MD", want: true}, + {name: "binary extension", filename: "image.png", want: false}, + {name: "no extension", filename: "LICENSE", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := IsTextFile(tt.filename); got != tt.want { + t.Fatalf("IsTextFile(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} + +func TestIsSmallFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + smallPath := filepath.Join(dir, "small.txt") + if err := os.WriteFile(smallPath, bytes.Repeat([]byte("x"), 1024), 0o644); err != nil { + t.Fatalf("failed to write small file: %v", err) + } + + largePath := filepath.Join(dir, "large.txt") + if err := os.WriteFile(largePath, bytes.Repeat([]byte("y"), 11*1024), 0o644); err != nil { + t.Fatalf("failed to write large file: %v", err) + } + + tests := []struct { + name string + path string + want bool + }{ + {name: "small file", path: smallPath, want: true}, + {name: "large file", path: largePath, want: false}, + {name: "missing file", path: filepath.Join(dir, "missing.txt"), want: false}, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := IsSmallFile(tc.path); got != tc.want { + t.Fatalf("IsSmallFile(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func TestFilterEmpty(t *testing.T) { + t.Parallel() + + input := []string{"feat", "", "test", " "} + want := []string{"feat", "test", " "} + + got := FilterEmpty(input) + + if len(got) != len(want) { + t.Fatalf("FilterEmpty returned %d items, want %d", len(got), len(want)) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("FilterEmpty mismatch at index %d: got %q want %q", i, got[i], want[i]) + } + } +} diff --git a/pkg/types/types_test.go b/pkg/types/types_test.go new file mode 100644 index 0000000..710fff2 --- /dev/null +++ b/pkg/types/types_test.go @@ -0,0 +1,50 @@ +package types + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestConfigJSONMarshalling(t *testing.T) { + t.Parallel() + + cfg := Config{ + GrokAPI: "https://api.x.ai/v1/chat/completions", + Repos: map[string]RepoConfig{ + "repo-a": { + Path: "/tmp/project", + LastRun: "2024-06-01T12:00:00Z", + }, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + + jsonStr := string(data) + if !strings.Contains(jsonStr, "\"grok_api\"") { + t.Fatalf("expected grok_api key in JSON: %s", jsonStr) + } + if !strings.Contains(jsonStr, "\"repos\"") { + t.Fatalf("expected repos key in JSON: %s", jsonStr) + } +} + +func TestCommitPromptContent(t *testing.T) { + t.Parallel() + + requiredFragments := []string{ + "Starts with a verb", + "Is clear and descriptive", + "Here are the changes", + } + + for _, fragment := range requiredFragments { + if !strings.Contains(CommitPrompt, fragment) { + t.Fatalf("CommitPrompt missing fragment %q", fragment) + } + } +}