| Philip Zeyliger | d3ac112 | 2025-05-14 02:54:18 +0000 | [diff] [blame^] | 1 | package git_tools |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "os/exec" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func setupTestRepo(t *testing.T) string { |
| 12 | // Create a temporary directory for the test repository |
| 13 | tempDir, err := os.MkdirTemp("", "git-tools-test") |
| 14 | if err != nil { |
| 15 | t.Fatalf("Failed to create temp directory: %v", err) |
| 16 | } |
| 17 | |
| 18 | // Initialize a git repository |
| 19 | cmd := exec.Command("git", "-C", tempDir, "init") |
| 20 | if out, err := cmd.CombinedOutput(); err != nil { |
| 21 | t.Fatalf("Failed to initialize git repo: %v - %s", err, out) |
| 22 | } |
| 23 | |
| 24 | // Configure git user |
| 25 | cmd = exec.Command("git", "-C", tempDir, "config", "user.email", "test@example.com") |
| 26 | if out, err := cmd.CombinedOutput(); err != nil { |
| 27 | t.Fatalf("Failed to configure git user email: %v - %s", err, out) |
| 28 | } |
| 29 | |
| 30 | cmd = exec.Command("git", "-C", tempDir, "config", "user.name", "Test User") |
| 31 | if out, err := cmd.CombinedOutput(); err != nil { |
| 32 | t.Fatalf("Failed to configure git user name: %v - %s", err, out) |
| 33 | } |
| 34 | |
| 35 | return tempDir |
| 36 | } |
| 37 | |
| 38 | func createAndCommitFile(t *testing.T, repoDir, filename, content string, stage bool) string { |
| 39 | filePath := filepath.Join(repoDir, filename) |
| 40 | if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { |
| 41 | t.Fatalf("Failed to write file: %v", err) |
| 42 | } |
| 43 | |
| 44 | if stage { |
| 45 | cmd := exec.Command("git", "-C", repoDir, "add", filename) |
| 46 | if out, err := cmd.CombinedOutput(); err != nil { |
| 47 | t.Fatalf("Failed to add file: %v - %s", err, out) |
| 48 | } |
| 49 | |
| 50 | cmd = exec.Command("git", "-C", repoDir, "commit", "-m", "Add "+filename) |
| 51 | if out, err := cmd.CombinedOutput(); err != nil { |
| 52 | t.Fatalf("Failed to commit file: %v - %s", err, out) |
| 53 | } |
| 54 | |
| 55 | // Get the commit hash |
| 56 | cmd = exec.Command("git", "-C", repoDir, "rev-parse", "HEAD") |
| 57 | out, err := cmd.Output() |
| 58 | if err != nil { |
| 59 | t.Fatalf("Failed to get commit hash: %v", err) |
| 60 | } |
| 61 | return string(out[:len(out)-1]) // Trim newline |
| 62 | } |
| 63 | |
| 64 | return "" |
| 65 | } |
| 66 | |
| 67 | func TestGitRawDiff(t *testing.T) { |
| 68 | repoDir := setupTestRepo(t) |
| 69 | defer os.RemoveAll(repoDir) |
| 70 | |
| 71 | // Create initial file |
| 72 | initHash := createAndCommitFile(t, repoDir, "test.txt", "initial content\n", true) |
| 73 | |
| 74 | // Modify the file |
| 75 | modHash := createAndCommitFile(t, repoDir, "test.txt", "initial content\nmodified content\n", true) |
| 76 | |
| 77 | // Test the diff between the two commits |
| 78 | diff, err := GitRawDiff(repoDir, initHash, modHash) |
| 79 | if err != nil { |
| 80 | t.Fatalf("GitRawDiff failed: %v", err) |
| 81 | } |
| 82 | |
| 83 | if len(diff) != 1 { |
| 84 | t.Fatalf("Expected 1 file in diff, got %d", len(diff)) |
| 85 | } |
| 86 | |
| 87 | if diff[0].Path != "test.txt" { |
| 88 | t.Errorf("Expected path to be test.txt, got %s", diff[0].Path) |
| 89 | } |
| 90 | |
| 91 | if diff[0].Status != "M" { |
| 92 | t.Errorf("Expected status to be M (modified), got %s", diff[0].Status) |
| 93 | } |
| 94 | |
| 95 | if diff[0].OldMode == "" || diff[0].NewMode == "" { |
| 96 | t.Error("Expected file modes to be present") |
| 97 | } |
| 98 | |
| 99 | if diff[0].OldHash == "" || diff[0].NewHash == "" { |
| 100 | t.Error("Expected file hashes to be present") |
| 101 | } |
| 102 | |
| 103 | // Test with invalid commit hash |
| 104 | _, err = GitRawDiff(repoDir, "invalid", modHash) |
| 105 | if err == nil { |
| 106 | t.Error("Expected error for invalid commit hash, got none") |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestGitShow(t *testing.T) { |
| 111 | repoDir := setupTestRepo(t) |
| 112 | defer os.RemoveAll(repoDir) |
| 113 | |
| 114 | // Create file and commit |
| 115 | commitHash := createAndCommitFile(t, repoDir, "test.txt", "test content\n", true) |
| 116 | |
| 117 | // Test GitShow |
| 118 | show, err := GitShow(repoDir, commitHash) |
| 119 | if err != nil { |
| 120 | t.Fatalf("GitShow failed: %v", err) |
| 121 | } |
| 122 | |
| 123 | if show == "" { |
| 124 | t.Error("Expected non-empty output from GitShow") |
| 125 | } |
| 126 | |
| 127 | // Test with invalid commit hash |
| 128 | _, err = GitShow(repoDir, "invalid") |
| 129 | if err == nil { |
| 130 | t.Error("Expected error for invalid commit hash, got none") |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestParseGitLog(t *testing.T) { |
| 135 | // Test with the format from --pretty="%H%x00%s%x00%d" |
| 136 | logOutput := "abc123\x00Initial commit\x00 (HEAD -> main, origin/main)\n" + |
| 137 | "def456\x00Add feature X\x00 (tag: v1.0.0)\n" + |
| 138 | "ghi789\x00Fix bug Y\x00" |
| 139 | |
| 140 | entries, err := parseGitLog(logOutput) |
| 141 | if err != nil { |
| 142 | t.Fatalf("parseGitLog returned error: %v", err) |
| 143 | } |
| 144 | |
| 145 | if len(entries) != 3 { |
| 146 | t.Fatalf("Expected 3 log entries, got %d", len(entries)) |
| 147 | } |
| 148 | |
| 149 | // Check first entry |
| 150 | if entries[0].Hash != "abc123" { |
| 151 | t.Errorf("Expected hash abc123, got %s", entries[0].Hash) |
| 152 | } |
| 153 | if len(entries[0].Refs) != 2 { |
| 154 | t.Errorf("Expected 2 refs, got %d", len(entries[0].Refs)) |
| 155 | } |
| 156 | if entries[0].Refs[0] != "main" || entries[0].Refs[1] != "origin/main" { |
| 157 | t.Errorf("Incorrect refs parsed: %v", entries[0].Refs) |
| 158 | } |
| 159 | if entries[0].Subject != "Initial commit" { |
| 160 | t.Errorf("Expected subject 'Initial commit', got '%s'", entries[0].Subject) |
| 161 | } |
| 162 | |
| 163 | // Check second entry |
| 164 | if entries[1].Hash != "def456" { |
| 165 | t.Errorf("Expected hash def456, got %s", entries[1].Hash) |
| 166 | } |
| 167 | if len(entries[1].Refs) != 1 { |
| 168 | t.Errorf("Expected 1 ref, got %d", len(entries[1].Refs)) |
| 169 | } |
| 170 | if entries[1].Refs[0] != "v1.0.0" { |
| 171 | t.Errorf("Incorrect tag parsed: %v", entries[1].Refs) |
| 172 | } |
| 173 | if entries[1].Subject != "Add feature X" { |
| 174 | t.Errorf("Expected subject 'Add feature X', got '%s'", entries[1].Subject) |
| 175 | } |
| 176 | |
| 177 | // Check third entry |
| 178 | if entries[2].Hash != "ghi789" { |
| 179 | t.Errorf("Expected hash ghi789, got %s", entries[2].Hash) |
| 180 | } |
| 181 | if len(entries[2].Refs) != 0 { |
| 182 | t.Errorf("Expected 0 refs, got %d", len(entries[2].Refs)) |
| 183 | } |
| 184 | if entries[2].Subject != "Fix bug Y" { |
| 185 | t.Errorf("Expected subject 'Fix bug Y', got '%s'", entries[2].Subject) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestParseRefs(t *testing.T) { |
| 190 | testCases := []struct { |
| 191 | decoration string |
| 192 | expected []string |
| 193 | }{ |
| 194 | {"(HEAD -> main, origin/main)", []string{"main", "origin/main"}}, |
| 195 | {"(tag: v1.0.0)", []string{"v1.0.0"}}, |
| 196 | {"(HEAD -> feature/branch, origin/feature/branch, tag: v0.9.0)", []string{"feature/branch", "origin/feature/branch", "v0.9.0"}}, |
| 197 | {" (tag: v2.0.0) ", []string{"v2.0.0"}}, |
| 198 | {"", nil}, |
| 199 | {" ", nil}, |
| 200 | {"()", nil}, |
| 201 | } |
| 202 | |
| 203 | for i, tc := range testCases { |
| 204 | refs := parseRefs(tc.decoration) |
| 205 | |
| 206 | if len(refs) != len(tc.expected) { |
| 207 | t.Errorf("Case %d: Expected %d refs, got %d", i, len(tc.expected), len(refs)) |
| 208 | continue |
| 209 | } |
| 210 | |
| 211 | for j, ref := range refs { |
| 212 | if j >= len(tc.expected) || ref != tc.expected[j] { |
| 213 | t.Errorf("Case %d: Expected ref '%s', got '%s'", i, tc.expected[j], ref) |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | func TestGitRecentLog(t *testing.T) { |
| 220 | // Create a temporary directory for the test repository |
| 221 | tmpDir, err := os.MkdirTemp("", "git-test-*") |
| 222 | if err != nil { |
| 223 | t.Fatalf("Failed to create temp dir: %v", err) |
| 224 | } |
| 225 | defer os.RemoveAll(tmpDir) |
| 226 | |
| 227 | // Initialize a git repository |
| 228 | initCmd := exec.Command("git", "-C", tmpDir, "init") |
| 229 | if out, err := initCmd.CombinedOutput(); err != nil { |
| 230 | t.Fatalf("Failed to initialize git repository: %v\n%s", err, out) |
| 231 | } |
| 232 | |
| 233 | // Configure git user for the test repository |
| 234 | exec.Command("git", "-C", tmpDir, "config", "user.name", "Test User").Run() |
| 235 | exec.Command("git", "-C", tmpDir, "config", "user.email", "test@example.com").Run() |
| 236 | |
| 237 | // Create initial commit |
| 238 | initialFile := filepath.Join(tmpDir, "initial.txt") |
| 239 | os.WriteFile(initialFile, []byte("initial content"), 0644) |
| 240 | exec.Command("git", "-C", tmpDir, "add", "initial.txt").Run() |
| 241 | initialCommitCmd := exec.Command("git", "-C", tmpDir, "commit", "-m", "Initial commit") |
| 242 | out, err := initialCommitCmd.CombinedOutput() |
| 243 | if err != nil { |
| 244 | t.Fatalf("Failed to create initial commit: %v\n%s", err, out) |
| 245 | } |
| 246 | |
| 247 | // Get the initial commit hash |
| 248 | initialCommitCmd = exec.Command("git", "-C", tmpDir, "rev-parse", "HEAD") |
| 249 | initialCommitBytes, err := initialCommitCmd.Output() |
| 250 | if err != nil { |
| 251 | t.Fatalf("Failed to get initial commit hash: %v", err) |
| 252 | } |
| 253 | initialCommitHash := strings.TrimSpace(string(initialCommitBytes)) |
| 254 | |
| 255 | // Add a second commit |
| 256 | secondFile := filepath.Join(tmpDir, "second.txt") |
| 257 | os.WriteFile(secondFile, []byte("second content"), 0644) |
| 258 | exec.Command("git", "-C", tmpDir, "add", "second.txt").Run() |
| 259 | secondCommitCmd := exec.Command("git", "-C", tmpDir, "commit", "-m", "Second commit") |
| 260 | out, err = secondCommitCmd.CombinedOutput() |
| 261 | if err != nil { |
| 262 | t.Fatalf("Failed to create second commit: %v\n%s", err, out) |
| 263 | } |
| 264 | |
| 265 | // Create a branch and tag |
| 266 | exec.Command("git", "-C", tmpDir, "branch", "test-branch").Run() |
| 267 | exec.Command("git", "-C", tmpDir, "tag", "-a", "v1.0.0", "-m", "Version 1.0.0").Run() |
| 268 | |
| 269 | // Add a third commit |
| 270 | thirdFile := filepath.Join(tmpDir, "third.txt") |
| 271 | os.WriteFile(thirdFile, []byte("third content"), 0644) |
| 272 | exec.Command("git", "-C", tmpDir, "add", "third.txt").Run() |
| 273 | thirdCommitCmd := exec.Command("git", "-C", tmpDir, "commit", "-m", "Third commit") |
| 274 | out, err = thirdCommitCmd.CombinedOutput() |
| 275 | if err != nil { |
| 276 | t.Fatalf("Failed to create third commit: %v\n%s", err, out) |
| 277 | } |
| 278 | |
| 279 | // Test GitRecentLog |
| 280 | log, err := GitRecentLog(tmpDir, initialCommitHash) |
| 281 | if err != nil { |
| 282 | t.Fatalf("GitRecentLog failed: %v", err) |
| 283 | } |
| 284 | |
| 285 | // No need to check specific entries in order |
| 286 | // Just validate we can find the second and third commits we created |
| 287 | |
| 288 | // Verify that we have the correct behavior with the fromCommit parameter: |
| 289 | // 1. We should find the second and third commits |
| 290 | // 2. We should NOT find the initial commit (it should be excluded) |
| 291 | foundThird := false |
| 292 | foundSecond := false |
| 293 | foundInitial := false |
| 294 | for _, entry := range log { |
| 295 | t.Logf("Found entry: %s - %s", entry.Hash, entry.Subject) |
| 296 | if entry.Subject == "Third commit" { |
| 297 | foundThird = true |
| 298 | } else if entry.Subject == "Second commit" { |
| 299 | foundSecond = true |
| 300 | } else if entry.Subject == "Initial commit" { |
| 301 | foundInitial = true |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | if !foundThird { |
| 306 | t.Errorf("Expected to find 'Third commit' in log entries") |
| 307 | } |
| 308 | if !foundSecond { |
| 309 | t.Errorf("Expected to find 'Second commit' in log entries") |
| 310 | } |
| 311 | if foundInitial { |
| 312 | t.Errorf("Should NOT have found 'Initial commit' in log entries (fromCommit parameter should exclude it)") |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | func TestParseRefsEdgeCases(t *testing.T) { |
| 317 | testCases := []struct { |
| 318 | name string |
| 319 | decoration string |
| 320 | expected []string |
| 321 | }{ |
| 322 | { |
| 323 | name: "Multiple tags and branches", |
| 324 | decoration: "(HEAD -> main, origin/main, tag: v1.0.0, tag: beta)", |
| 325 | expected: []string{"main", "origin/main", "v1.0.0", "beta"}, |
| 326 | }, |
| 327 | { |
| 328 | name: "Leading/trailing whitespace", |
| 329 | decoration: " (HEAD -> main) ", |
| 330 | expected: []string{"main"}, |
| 331 | }, |
| 332 | { |
| 333 | name: "No parentheses", |
| 334 | decoration: "HEAD -> main, tag: v1.0.0", |
| 335 | expected: []string{"main", "v1.0.0"}, |
| 336 | }, |
| 337 | { |
| 338 | name: "Feature branch with slash", |
| 339 | decoration: "(HEAD -> feature/new-ui)", |
| 340 | expected: []string{"feature/new-ui"}, |
| 341 | }, |
| 342 | { |
| 343 | name: "Only HEAD with no branch", |
| 344 | decoration: "(HEAD)", |
| 345 | expected: []string{"HEAD"}, |
| 346 | }, |
| 347 | } |
| 348 | |
| 349 | for _, tc := range testCases { |
| 350 | t.Run(tc.name, func(t *testing.T) { |
| 351 | refs := parseRefs(tc.decoration) |
| 352 | |
| 353 | if len(refs) != len(tc.expected) { |
| 354 | t.Errorf("%s: Expected %d refs, got %d", tc.name, len(tc.expected), len(refs)) |
| 355 | return |
| 356 | } |
| 357 | |
| 358 | for i, ref := range refs { |
| 359 | if ref != tc.expected[i] { |
| 360 | t.Errorf("%s: Expected ref[%d] = '%s', got '%s'", tc.name, i, tc.expected[i], ref) |
| 361 | } |
| 362 | } |
| 363 | }) |
| 364 | } |
| 365 | } |