cloudflare/pint

Public

mirrored from https://github.com/cloudflare/pintAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.81.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

internal/git/changes_test.go

1593lines · modecode

1package git_test
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "os"
8 "regexp"
9 "strings"
10 "testing"
11
12 "github.com/neilotoole/slogt"
13 "github.com/stretchr/testify/require"
14
15 "github.com/cloudflare/pint/internal/git"
16)
17
18func debugGitRun(t *testing.T) git.CommandRunner {
19 return func(args ...string) ([]byte, error) {
20 out, err := git.RunGit(args...)
21 if err == nil {
22 if len(out) == 0 {
23 t.Logf("%s ~> no stdout", strings.Join(args, " "))
24 } else {
25 t.Logf("%s\n---\n%s---", strings.Join(args, " "), string(out))
26 }
27 }
28 return out, err
29 }
30}
31
32func mustRun(t *testing.T, args ...string) {
33 _, err := debugGitRun(t)(args...)
34 require.NoError(t, err, strings.Join(args, " "))
35}
36
37func gitCommit(t *testing.T, message string) {
38 t.Setenv("GIT_AUTHOR_NAME", "pint")
39 t.Setenv("GIT_AUTHOR_EMAIL", "pint@example.com")
40 t.Setenv("GIT_COMMITTER_NAME", "pint")
41 t.Setenv("GIT_COMMITTER_EMAIL", "pint")
42 mustRun(t, "commit", "-am", "commit "+message)
43}
44
45func TestChanges(t *testing.T) {
46 type testCaseT struct {
47 setup func(t *testing.T) git.CommandRunner
48 title string
49 err string
50 changes []*git.FileChange
51 }
52
53 testCases := []testCaseT{
54 {
55 title: "git log error",
56 setup: func(_ *testing.T) git.CommandRunner {
57 cmd := func(args ...string) ([]byte, error) {
58 return nil, fmt.Errorf("mock git error: %v", args)
59 }
60 return cmd
61 },
62 changes: nil,
63 err: "failed to get the list of modified files from git: mock git error: [log --reverse --no-merges --first-parent --format=%H --name-status main..HEAD]",
64 },
65 {
66 title: "chmod",
67 setup: func(t *testing.T) git.CommandRunner {
68 mustRun(t, "init", "--initial-branch=main", ".")
69 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
70 mustRun(t, "add", "index.txt")
71 gitCommit(t, "init")
72
73 mustRun(t, "checkout", "-b", "v2")
74 require.NoError(t, os.Chmod("index.txt", 0o755))
75 mustRun(t, "add", "index.txt")
76 gitCommit(t, "chmod")
77
78 return debugGitRun(t)
79 },
80 changes: []*git.FileChange{
81 {
82 Commits: []string{"1"},
83 Path: git.PathDiff{
84 Before: git.Path{
85 Name: "index.txt",
86 Type: git.File,
87 },
88 After: git.Path{
89 Name: "index.txt",
90 Type: git.File,
91 },
92 },
93 Body: git.BodyDiff{
94 Before: []byte("foo"),
95 After: []byte("foo"),
96 Lines: []git.LineNumber{},
97 },
98 },
99 },
100 err: "",
101 },
102 {
103 title: "dir -> file",
104 setup: func(t *testing.T) git.CommandRunner {
105 mustRun(t, "init", "--initial-branch=main", ".")
106 require.NoError(t, os.Mkdir("index.txt", 0o755))
107 require.NoError(t, os.WriteFile("index.txt/.keep", []byte("keep"), 0o644))
108 mustRun(t, "add", "index.txt")
109 gitCommit(t, "init")
110
111 mustRun(t, "checkout", "-b", "v2")
112 require.NoError(t, os.RemoveAll("index.txt"))
113 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
114 mustRun(t, "add", "index.txt")
115 gitCommit(t, "chmod")
116
117 return debugGitRun(t)
118 },
119 changes: []*git.FileChange{
120 {
121 Commits: []string{"1"},
122 Path: git.PathDiff{
123 Before: git.Path{
124 Name: "index.txt",
125 Type: git.Dir,
126 },
127 After: git.Path{
128 Name: "index.txt",
129 Type: git.File,
130 },
131 },
132 Body: git.BodyDiff{
133 Before: nil,
134 After: []byte("foo"),
135 Lines: []git.LineNumber{
136 {Before: 0, After: 1, Modified: true},
137 },
138 },
139 },
140 {
141 Commits: []string{"1"},
142 Path: git.PathDiff{
143 Before: git.Path{
144 Name: "index.txt/.keep",
145 Type: git.File,
146 },
147 After: git.Path{
148 Name: "index.txt/.keep",
149 Type: git.Missing,
150 },
151 },
152 Body: git.BodyDiff{
153 Before: []byte("keep"),
154 After: nil,
155 Lines: []git.LineNumber{
156 {Before: 1, After: 0, Modified: false},
157 },
158 },
159 },
160 },
161 err: "",
162 },
163 {
164 title: "delete and re-add",
165 setup: func(t *testing.T) git.CommandRunner {
166 mustRun(t, "init", "--initial-branch=main", ".")
167 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
168 mustRun(t, "add", "index.txt")
169 gitCommit(t, "init")
170
171 mustRun(t, "checkout", "-b", "v2")
172 require.NoError(t, os.Remove("index.txt"))
173 mustRun(t, "add", "index.txt")
174 gitCommit(t, "rm")
175 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
176 mustRun(t, "add", "index.txt")
177 gitCommit(t, "add")
178
179 return debugGitRun(t)
180 },
181 changes: []*git.FileChange{
182 {
183 Commits: []string{"1", "2"},
184 Path: git.PathDiff{
185 Before: git.Path{
186 Name: "index.txt",
187 Type: git.File,
188 },
189 After: git.Path{
190 Name: "index.txt",
191 Type: git.File,
192 },
193 },
194 Body: git.BodyDiff{
195 Before: []byte("foo"),
196 After: []byte("foo"),
197 Lines: []git.LineNumber{},
198 },
199 },
200 },
201 err: "",
202 },
203 {
204 title: "file -> symlink",
205 setup: func(t *testing.T) git.CommandRunner {
206 mustRun(t, "init", "--initial-branch=main", ".")
207 require.NoError(t, os.WriteFile("index.txt", []byte("foo\n1\n"), 0o644))
208 mustRun(t, "add", "index.txt")
209 require.NoError(t, os.WriteFile("second file.txt", []byte("bar\n1\n"), 0o644))
210 mustRun(t, "add", "second file.txt")
211 gitCommit(t, "init")
212
213 mustRun(t, "checkout", "-b", "v2")
214 require.NoError(t, os.Remove("second file.txt"))
215 require.NoError(t, os.Symlink("index.txt", "second file.txt"))
216 mustRun(t, "add", "second file.txt")
217 gitCommit(t, "symlink")
218
219 return debugGitRun(t)
220 },
221 changes: []*git.FileChange{
222 {
223 Commits: []string{"1"},
224 Path: git.PathDiff{
225 Before: git.Path{
226 Name: "second file.txt",
227 Type: git.File,
228 },
229 After: git.Path{
230 Name: "second file.txt",
231 Type: git.Symlink,
232 SymlinkTarget: "index.txt",
233 },
234 },
235 Body: git.BodyDiff{
236 Before: []byte("bar\n1\n"),
237 After: []byte("foo\n1\n"),
238 Lines: []git.LineNumber{
239 {Before: 0, After: 1, Modified: true},
240 {Before: 0, After: 2, Modified: true},
241 },
242 },
243 },
244 },
245 err: "",
246 },
247 {
248 title: "rename partial",
249 setup: func(t *testing.T) git.CommandRunner {
250 mustRun(t, "init", "--initial-branch=main", ".")
251 require.NoError(t, os.WriteFile("index.txt", []byte("1\n2\n3\n4\n5\n6\n7\n8\n9\n"), 0o644))
252 mustRun(t, "add", "index.txt")
253 gitCommit(t, "init")
254
255 mustRun(t, "checkout", "-b", "v2")
256 mustRun(t, "mv", "index.txt", "second.txt")
257 require.NoError(t, os.WriteFile("second.txt", []byte("1\n2\n3\n4\n5\nX\nX\nX\nX\n"), 0o644))
258 mustRun(t, "add", "second.txt")
259 gitCommit(t, "mv")
260
261 return debugGitRun(t)
262 },
263 changes: []*git.FileChange{
264 {
265 Commits: []string{"1"},
266 Path: git.PathDiff{
267 Before: git.Path{
268 Name: "index.txt",
269 Type: git.File,
270 },
271 After: git.Path{
272 Name: "second.txt",
273 Type: git.File,
274 },
275 },
276 Body: git.BodyDiff{
277 Before: []byte("1\n2\n3\n4\n5\n6\n7\n8\n9\n"),
278 After: []byte("1\n2\n3\n4\n5\nX\nX\nX\nX\n"),
279 Lines: []git.LineNumber{
280 {Before: 6, After: 6, Modified: true},
281 {Before: 7, After: 7, Modified: true},
282 {Before: 8, After: 8, Modified: true},
283 {Before: 9, After: 9, Modified: true},
284 },
285 },
286 },
287 },
288 err: "",
289 },
290 {
291 title: "rename 100% and edit",
292 setup: func(t *testing.T) git.CommandRunner {
293 mustRun(t, "init", "--initial-branch=main", ".")
294 require.NoError(t, os.WriteFile("index.txt", []byte("1\n2\n3\n4\n5\n6\n7\n8\n9\n"), 0o644))
295 mustRun(t, "add", "index.txt")
296 gitCommit(t, "init")
297
298 mustRun(t, "checkout", "-b", "v2")
299 mustRun(t, "mv", "index.txt", "second.txt")
300 gitCommit(t, "mv")
301 require.NoError(t, os.WriteFile("second.txt", []byte("1\n2\n3\n4\n5\nX\n7\n8\n9\n"), 0o644))
302 mustRun(t, "add", "second.txt")
303 gitCommit(t, "edit")
304
305 return debugGitRun(t)
306 },
307 changes: []*git.FileChange{
308 {
309 Commits: []string{"1", "2"},
310 Path: git.PathDiff{
311 Before: git.Path{
312 Name: "index.txt",
313 Type: git.File,
314 },
315 After: git.Path{
316 Name: "second.txt",
317 Type: git.File,
318 },
319 },
320 Body: git.BodyDiff{
321 Before: []byte("1\n2\n3\n4\n5\n6\n7\n8\n9\n"),
322 After: []byte("1\n2\n3\n4\n5\nX\n7\n8\n9\n"),
323 Lines: []git.LineNumber{
324 {Before: 6, After: 6, Modified: true},
325 },
326 },
327 },
328 },
329 err: "",
330 },
331 {
332 title: "add file, add another",
333 setup: func(t *testing.T) git.CommandRunner {
334 mustRun(t, "init", "--initial-branch=main", ".")
335 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
336 mustRun(t, "add", "index.txt")
337 gitCommit(t, "init")
338
339 mustRun(t, "checkout", "-b", "v2")
340 require.NoError(t, os.WriteFile("second.txt", []byte("second"), 0o644))
341 mustRun(t, "add", "second.txt")
342 require.NoError(t, os.WriteFile("third.txt", []byte("third"), 0o644))
343 mustRun(t, "add", "third.txt")
344 gitCommit(t, "add two more")
345
346 return debugGitRun(t)
347 },
348 changes: []*git.FileChange{
349 {
350 Commits: []string{"1"},
351 Path: git.PathDiff{
352 Before: git.Path{
353 Name: "",
354 Type: git.Missing,
355 },
356 After: git.Path{
357 Name: "second.txt",
358 Type: git.File,
359 },
360 },
361 Body: git.BodyDiff{
362 After: []byte("second"),
363 Lines: []git.LineNumber{
364 {Before: 0, After: 1, Modified: true},
365 },
366 },
367 },
368 {
369 Commits: []string{"1"},
370 Path: git.PathDiff{
371 Before: git.Path{
372 Name: "",
373 Type: git.Missing,
374 },
375 After: git.Path{
376 Name: "third.txt",
377 Type: git.File,
378 },
379 },
380 Body: git.BodyDiff{
381 After: []byte("third"),
382 Lines: []git.LineNumber{
383 {Before: 0, After: 1, Modified: true},
384 },
385 },
386 },
387 },
388 err: "",
389 },
390 {
391 title: "delete file",
392 setup: func(t *testing.T) git.CommandRunner {
393 mustRun(t, "init", "--initial-branch=main", ".")
394 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
395 mustRun(t, "add", "index.txt")
396 require.NoError(t, os.WriteFile("second.txt", []byte("second"), 0o644))
397 mustRun(t, "add", "second.txt")
398 gitCommit(t, "init")
399
400 mustRun(t, "checkout", "-b", "v2")
401 require.NoError(t, os.Remove("second.txt"))
402 mustRun(t, "add", "second.txt")
403 gitCommit(t, "rm second")
404
405 return debugGitRun(t)
406 },
407 changes: []*git.FileChange{
408 {
409 Commits: []string{"1"},
410 Path: git.PathDiff{
411 Before: git.Path{
412 Name: "second.txt",
413 Type: git.File,
414 },
415 After: git.Path{
416 Name: "second.txt",
417 Type: git.Missing,
418 },
419 },
420 Body: git.BodyDiff{
421 Before: []byte("second"),
422 Lines: []git.LineNumber{
423 {Before: 1, After: 0, Modified: false},
424 },
425 },
426 },
427 },
428 err: "",
429 },
430 {
431 title: "delete symlink",
432 setup: func(t *testing.T) git.CommandRunner {
433 mustRun(t, "init", "--initial-branch=main", ".")
434 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
435 mustRun(t, "add", "index.txt")
436 require.NoError(t, os.Symlink("index.txt", "second.txt"))
437 mustRun(t, "add", "second.txt")
438 gitCommit(t, "init")
439
440 mustRun(t, "checkout", "-b", "v2")
441 require.NoError(t, os.Remove("second.txt"))
442 mustRun(t, "add", "second.txt")
443 gitCommit(t, "rm second")
444
445 return debugGitRun(t)
446 },
447 changes: []*git.FileChange{
448 {
449 Commits: []string{"1"},
450 Path: git.PathDiff{
451 Before: git.Path{
452 Name: "second.txt",
453 Type: git.Symlink,
454 SymlinkTarget: "index.txt",
455 },
456 After: git.Path{
457 Name: "second.txt",
458 Type: git.Missing,
459 },
460 },
461 Body: git.BodyDiff{
462 Before: []byte("foo"),
463 Lines: []git.LineNumber{
464 {Before: 1, After: 0, Modified: false},
465 },
466 },
467 },
468 },
469 err: "",
470 },
471 {
472 title: "delete directory with symlinks",
473 setup: func(t *testing.T) git.CommandRunner {
474 mustRun(t, "init", "--initial-branch=main", ".")
475 require.NoError(t, os.WriteFile("index.txt", []byte("foo"), 0o644))
476 mustRun(t, "add", "index.txt")
477 require.NoError(t, os.Mkdir("dir", 0o755))
478 require.NoError(t, os.Symlink("../index.txt", "dir/first.txt"))
479 require.NoError(t, os.Symlink("../index.txt", "dir/second.txt"))
480 mustRun(t, "add", "dir")
481 gitCommit(t, "init")
482
483 mustRun(t, "checkout", "-b", "v2")
484 require.NoError(t, os.RemoveAll("dir"))
485 mustRun(t, "add", "dir")
486 gitCommit(t, "rm dir")
487
488 return debugGitRun(t)
489 },
490 changes: []*git.FileChange{
491 {
492 Commits: []string{"1"},
493 Path: git.PathDiff{
494 Before: git.Path{
495 Name: "dir/first.txt",
496 Type: git.Symlink,
497 SymlinkTarget: "index.txt",
498 },
499 After: git.Path{
500 Name: "dir/first.txt",
501 Type: git.Missing,
502 },
503 },
504 Body: git.BodyDiff{
505 Before: []byte("foo"),
506 Lines: []git.LineNumber{
507 {Before: 1, After: 0, Modified: false},
508 },
509 },
510 },
511 {
512 Commits: []string{"1"},
513 Path: git.PathDiff{
514 Before: git.Path{
515 Name: "dir/second.txt",
516 Type: git.Symlink,
517 SymlinkTarget: "index.txt",
518 },
519 After: git.Path{
520 Name: "dir/second.txt",
521 Type: git.Missing,
522 },
523 },
524 Body: git.BodyDiff{
525 Before: []byte("foo"),
526 Lines: []git.LineNumber{
527 {Before: 1, After: 0, Modified: false},
528 },
529 },
530 },
531 },
532 err: "",
533 },
534 {
535 title: "symlink target changed",
536 setup: func(t *testing.T) git.CommandRunner {
537 mustRun(t, "init", "--initial-branch=main", ".")
538 require.NoError(t, os.WriteFile("index.txt", []byte("foo\n1\n"), 0o644))
539 mustRun(t, "add", "index.txt")
540 require.NoError(t, os.WriteFile("second file.txt", []byte("bar\n1\n"), 0o644))
541 mustRun(t, "add", "second file.txt")
542 require.NoError(t, os.Mkdir("dir", 0o755))
543 require.NoError(t, os.Symlink("../index.txt", "dir/first.txt"))
544 require.NoError(t, os.Symlink("../second file.txt", "dir/second.txt"))
545 mustRun(t, "add", "dir")
546 gitCommit(t, "init")
547
548 mustRun(t, "checkout", "-b", "v2")
549 require.NoError(t, os.Remove("dir/second.txt"))
550 require.NoError(t, os.Symlink("first.txt", "dir/second.txt"))
551 mustRun(t, "add", "dir")
552 gitCommit(t, "symlink change")
553
554 return debugGitRun(t)
555 },
556 changes: []*git.FileChange{
557 {
558 Commits: []string{"1"},
559 Path: git.PathDiff{
560 Before: git.Path{
561 Name: "dir/second.txt",
562 Type: git.Symlink,
563 SymlinkTarget: "second file.txt",
564 },
565 After: git.Path{
566 Name: "dir/second.txt",
567 Type: git.Symlink,
568 SymlinkTarget: "index.txt",
569 },
570 },
571 Body: git.BodyDiff{
572 Before: []byte("bar\n1\n"),
573 After: []byte("foo\n1\n"),
574 Lines: []git.LineNumber{
575 {Before: 0, After: 1, Modified: true},
576 {Before: 0, After: 2, Modified: true},
577 },
578 },
579 },
580 },
581 err: "",
582 },
583 {
584 title: "rule modified then file renamed",
585 setup: func(t *testing.T) git.CommandRunner {
586 mustRun(t, "init", "--initial-branch=main", ".")
587 require.NoError(t, os.WriteFile("main.txt", []byte("l1\nl2\nl3\n"), 0o644))
588 mustRun(t, "add", "main.txt")
589 gitCommit(t, "init")
590
591 mustRun(t, "checkout", "-b", "v2")
592 require.NoError(t, os.WriteFile("main.txt", []byte("l1\nl3\n"), 0o644))
593 mustRun(t, "add", "main.txt")
594 gitCommit(t, "edit")
595
596 mustRun(t, "mv", "main.txt", "pr.txt")
597 gitCommit(t, "rename")
598
599 return debugGitRun(t)
600 },
601 changes: []*git.FileChange{
602 {
603 Commits: []string{"1", "2"},
604 Path: git.PathDiff{
605 Before: git.Path{
606 Name: "main.txt",
607 Type: git.File,
608 },
609 After: git.Path{
610 Name: "pr.txt",
611 Type: git.File,
612 },
613 },
614 Body: git.BodyDiff{
615 Before: []byte("l1\nl2\nl3\n"),
616 After: []byte("l1\nl3\n"),
617 Lines: []git.LineNumber{
618 {Before: 2, After: 0, Modified: false},
619 {Before: 3, After: 2, Modified: false},
620 },
621 },
622 },
623 },
624 err: "",
625 },
626 {
627 title: "directory_renamed",
628 setup: func(t *testing.T) git.CommandRunner {
629 mustRun(t, "init", "--initial-branch=main", ".")
630 require.NoError(t, os.Mkdir("dir1", 0o755))
631 require.NoError(t, os.Mkdir("dir1/rules", 0o755))
632 require.NoError(t, os.WriteFile("dir1/rules/file1.txt", []byte("a1\na2\na3\n"), 0o644))
633 require.NoError(t, os.WriteFile("dir1/rules/file2.txt", []byte("b1\nb2"), 0o644))
634 mustRun(t, "add", "dir1")
635 gitCommit(t, "init")
636
637 mustRun(t, "checkout", "-b", "v1")
638 mustRun(t, "mv", "dir1", "dir2")
639 gitCommit(t, "rename")
640
641 return debugGitRun(t)
642 },
643 changes: []*git.FileChange{
644 {
645 Commits: []string{"1"},
646 Path: git.PathDiff{
647 Before: git.Path{
648 Name: "dir1/rules/file1.txt",
649 Type: git.File,
650 },
651 After: git.Path{
652 Name: "dir2/rules/file1.txt",
653 Type: git.File,
654 },
655 },
656 Body: git.BodyDiff{
657 Before: []byte("a1\na2\na3\n"),
658 After: []byte("a1\na2\na3\n"),
659 // Renamed without content changes.
660 Lines: []git.LineNumber{},
661 },
662 },
663 {
664 Commits: []string{"1"},
665 Path: git.PathDiff{
666 Before: git.Path{
667 Name: "dir1/rules/file2.txt",
668 Type: git.File,
669 },
670 After: git.Path{
671 Name: "dir2/rules/file2.txt",
672 Type: git.File,
673 },
674 },
675 Body: git.BodyDiff{
676 Before: []byte("b1\nb2"),
677 After: []byte("b1\nb2"),
678 // Renamed without content changes.
679 Lines: []git.LineNumber{},
680 },
681 },
682 },
683 err: "",
684 },
685 {
686 title: "rule partially replaced",
687 setup: func(t *testing.T) git.CommandRunner {
688 mustRun(t, "init", "--initial-branch=main", ".")
689 require.NoError(t, os.WriteFile("main.txt", []byte("l1\nl2\nl3\n"), 0o644))
690 mustRun(t, "add", "main.txt")
691 gitCommit(t, "init")
692
693 mustRun(t, "checkout", "-b", "v2")
694 require.NoError(t, os.WriteFile("main.txt", []byte("l1\nl3\n"), 0o644))
695 mustRun(t, "add", "main.txt")
696 gitCommit(t, "edit")
697
698 mustRun(t, "mv", "main.txt", "pr.txt")
699 gitCommit(t, "rename")
700
701 return debugGitRun(t)
702 },
703 changes: []*git.FileChange{
704 {
705 Commits: []string{"1", "2"},
706 Path: git.PathDiff{
707 Before: git.Path{
708 Name: "main.txt",
709 Type: git.File,
710 },
711 After: git.Path{
712 Name: "pr.txt",
713 Type: git.File,
714 },
715 },
716 Body: git.BodyDiff{
717 Before: []byte("l1\nl2\nl3\n"),
718 After: []byte("l1\nl3\n"),
719 Lines: []git.LineNumber{
720 {Before: 2, After: 0, Modified: false},
721 {Before: 3, After: 2, Modified: false},
722 },
723 },
724 },
725 },
726 err: "",
727 },
728 }
729
730 for _, tc := range testCases {
731 t.Run(tc.title, func(t *testing.T) {
732 slog.SetDefault(slogt.New(t))
733
734 dir := t.TempDir()
735 t.Chdir(dir)
736
737 cmd := tc.setup(t)
738 changes, err := git.Changes(cmd, "main", git.NewPathFilter(nil, nil, nil))
739 if tc.err != "" {
740 require.EqualError(t, err, tc.err)
741 require.Nil(t, changes)
742 } else {
743 require.NoError(t, err)
744 require.Len(t, changes, len(tc.changes))
745 for i := range tc.changes {
746 require.Len(t, changes[i].Commits, len(tc.changes[i].Commits), "changes[%d].Commits", i)
747 require.Equal(t, tc.changes[i].Path, changes[i].Path, "changes[%d].Path", i)
748 require.Equal(t, tc.changes[i].Body, changes[i].Body, "changes[%d].Body", i)
749 }
750 require.Len(t, changes, len(tc.changes))
751 }
752 })
753 }
754}
755
756func TestChangesParseDiff(t *testing.T) {
757 type testCaseT struct {
758 title string
759 err string
760 mock git.CommandRunner
761 changes []*git.FileChange
762 }
763
764 testCases := []testCaseT{
765 {
766 title: "empty line in git log output",
767 mock: func(args ...string) ([]byte, error) {
768 if args[0] == "log" {
769 // Empty line should trigger len(parts) == 0
770 return []byte("abc123\n\nM\tfile.txt\n"), nil
771 }
772 if args[0] == "ls-tree" {
773 return []byte("100644 blob abc123def456\tfile.txt\n"), nil
774 }
775 if args[0] == "cat-file" {
776 return []byte("content"), nil
777 }
778 if args[0] == "diff" {
779 return nil, nil
780 }
781 return nil, nil
782 },
783 changes: []*git.FileChange{
784 {
785 Commits: []string{"1"},
786 Path: git.PathDiff{
787 Before: git.Path{
788 Name: "file.txt",
789 Type: git.File,
790 },
791 After: git.Path{
792 Name: "file.txt",
793 Type: git.File,
794 },
795 },
796 Body: git.BodyDiff{
797 Before: []byte("content"),
798 After: []byte("content"),
799 Lines: []git.LineNumber{},
800 },
801 },
802 },
803 },
804 {
805 title: "ls-tree malformed line - less than 3 space-separated parts",
806 mock: func(args ...string) ([]byte, error) {
807 if args[0] == "log" {
808 return []byte("abc123\nA\tnewfile.txt\n"), nil
809 }
810 if args[0] == "ls-tree" {
811 // Only 2 parts instead of 3
812 return []byte("100644 blob\n"), nil
813 }
814 if args[0] == "cat-file" {
815 return []byte("content"), nil
816 }
817 return nil, nil
818 },
819 changes: []*git.FileChange{
820 {
821 Commits: []string{"1"},
822 Path: git.PathDiff{
823 Before: git.Path{
824 Name: "",
825 Type: git.Missing,
826 },
827 After: git.Path{
828 Name: "newfile.txt",
829 Type: git.Missing,
830 },
831 },
832 Body: git.BodyDiff{
833 Before: nil,
834 After: []byte("content"),
835 Lines: []git.LineNumber{},
836 },
837 },
838 },
839 },
840 {
841 title: "ls-tree line missing tab separator",
842 mock: func(args ...string) ([]byte, error) {
843 if args[0] == "log" {
844 return []byte("abc123\nA\tnewfile.txt\n"), nil
845 }
846 if args[0] == "ls-tree" {
847 // Has 3 space parts but no tab in third part
848 return []byte("100644 blob abc123def456\n"), nil
849 }
850 if args[0] == "cat-file" {
851 return []byte("content"), nil
852 }
853 return nil, nil
854 },
855 changes: []*git.FileChange{
856 {
857 Commits: []string{"1"},
858 Path: git.PathDiff{
859 Before: git.Path{
860 Name: "",
861 Type: git.Missing,
862 },
863 After: git.Path{
864 Name: "newfile.txt",
865 Type: git.Missing,
866 },
867 },
868 Body: git.BodyDiff{
869 Before: nil,
870 After: []byte("content"),
871 Lines: []git.LineNumber{},
872 },
873 },
874 },
875 },
876 {
877 title: "ls-tree returns different path",
878 mock: func(args ...string) ([]byte, error) {
879 if args[0] == "log" {
880 return []byte("abc123\nA\tnewfile.txt\n"), nil
881 }
882 if args[0] == "ls-tree" {
883 // Returns a different file path
884 return []byte("100644 blob abc123def456\totherfile.txt\n"), nil
885 }
886 if args[0] == "cat-file" {
887 return []byte("content"), nil
888 }
889 return nil, nil
890 },
891 changes: []*git.FileChange{
892 {
893 Commits: []string{"1"},
894 Path: git.PathDiff{
895 Before: git.Path{
896 Name: "",
897 Type: git.Missing,
898 },
899 After: git.Path{
900 Name: "newfile.txt",
901 Type: git.Missing,
902 },
903 },
904 Body: git.BodyDiff{
905 Before: nil,
906 After: []byte("content"),
907 Lines: []git.LineNumber{},
908 },
909 },
910 },
911 },
912 {
913 title: "ls-tree returns tag object type",
914 mock: func(args ...string) ([]byte, error) {
915 if args[0] == "log" {
916 return []byte("abc123\nA\tnewfile.txt\n"), nil
917 }
918 if args[0] == "ls-tree" {
919 // Object type is "tag" not "blob" or "tree"
920 return []byte("100644 tag abc123def456\tnewfile.txt\n"), nil
921 }
922 if args[0] == "cat-file" {
923 return []byte("content"), nil
924 }
925 return nil, nil
926 },
927 changes: []*git.FileChange{
928 {
929 Commits: []string{"1"},
930 Path: git.PathDiff{
931 Before: git.Path{
932 Name: "",
933 Type: git.Missing,
934 },
935 After: git.Path{
936 Name: "newfile.txt",
937 Type: git.Missing,
938 },
939 },
940 Body: git.BodyDiff{
941 Before: nil,
942 After: []byte("content"),
943 Lines: []git.LineNumber{},
944 },
945 },
946 },
947 },
948 {
949 // git diff command returns an error for a modified file.
950 title: "git diff error",
951 err: "failed to run git diff for file.txt: git diff for file.txt: mock diff error",
952 mock: func(args ...string) ([]byte, error) {
953 if args[0] == "log" {
954 return []byte("abc123\nM\tfile.txt\n"), nil
955 }
956 if args[0] == "ls-tree" {
957 return []byte("100644 blob abc123def456\tfile.txt\n"), nil
958 }
959 if args[0] == "cat-file" {
960 return []byte("content"), nil
961 }
962 if args[0] == "diff" {
963 return nil, errors.New("mock diff error")
964 }
965 return nil, nil
966 },
967 },
968 {
969 title: "ls-tree error on before commit",
970 mock: func(args ...string) ([]byte, error) {
971 if args[0] == "log" {
972 return []byte("abc123\nM\tfile.txt\n"), nil
973 }
974 if args[0] == "ls-tree" {
975 if strings.Contains(strings.Join(args, " "), "abc123^") {
976 return nil, errors.New("mock ls-tree error")
977 }
978 return []byte("100644 blob abc123def456\tfile.txt\n"), nil
979 }
980 if args[0] == "cat-file" {
981 // Fail for the before commit blob ref.
982 if strings.Contains(args[2], "abc123^") {
983 return nil, errors.New("mock cat-file error")
984 }
985 return []byte("new content"), nil
986 }
987 if args[0] == "diff" {
988 return nil, nil
989 }
990 return nil, nil
991 },
992 changes: []*git.FileChange{
993 {
994 Commits: []string{"1"},
995 Path: git.PathDiff{
996 Before: git.Path{
997 Name: "file.txt",
998 Type: git.Missing,
999 },
1000 After: git.Path{
1001 Name: "file.txt",
1002 Type: git.File,
1003 },
1004 },
1005 Body: git.BodyDiff{
1006 Before: nil,
1007 After: []byte("new content"),
1008 Lines: []git.LineNumber{
1009 {Before: 0, After: 1, Modified: true},
1010 },
1011 },
1012 },
1013 },
1014 },
1015 {
1016 // File content lines starting with "--- " or "+++ " inside a hunk
1017 // must be treated as deletions/additions, not as diff headers.
1018 // The inHunk flag ensures headers are only matched outside hunks.
1019 title: "hunk content with triple-dash and triple-plus lines",
1020 mock: func(args ...string) ([]byte, error) {
1021 if args[0] == "log" {
1022 return []byte("abc123\nM\tfile.txt\n"), nil
1023 }
1024 if args[0] == "ls-tree" {
1025 return []byte("100644 blob abc123def456\tfile.txt\n"), nil
1026 }
1027 if args[0] == "cat-file" {
1028 if strings.Contains(args[2], "abc123^") {
1029 return []byte("-- old\nkeep\n"), nil
1030 }
1031 return []byte("++ new\nkeep\n"), nil
1032 }
1033 if args[0] == "diff" {
1034 return []byte(
1035 "diff --git a/file.txt b/file.txt\n" +
1036 "--- a/file.txt\n" +
1037 "+++ b/file.txt\n" +
1038 "@@ -1,2 +1,2 @@\n" +
1039 "--- old\n" +
1040 "+++ new\n" +
1041 " keep\n",
1042 ), nil
1043 }
1044 return nil, nil
1045 },
1046 changes: []*git.FileChange{
1047 {
1048 Commits: []string{"1"},
1049 Path: git.PathDiff{
1050 Before: git.Path{
1051 Name: "file.txt",
1052 SymlinkTarget: "",
1053 Type: git.File,
1054 },
1055 After: git.Path{
1056 Name: "file.txt",
1057 SymlinkTarget: "",
1058 Type: git.File,
1059 },
1060 },
1061 Body: git.BodyDiff{
1062 Before: []byte("-- old\nkeep\n"),
1063 After: []byte("++ new\nkeep\n"),
1064 Lines: git.LineNumbers{
1065 {Before: 1, After: 1, Modified: true},
1066 },
1067 },
1068 },
1069 },
1070 },
1071 }
1072
1073 for _, tc := range testCases {
1074 t.Run(tc.title, func(t *testing.T) {
1075 slog.SetDefault(slogt.New(t))
1076
1077 changes, err := git.Changes(tc.mock, "main", git.NewPathFilter(nil, nil, nil))
1078 if tc.err != "" {
1079 require.EqualError(t, err, tc.err)
1080 require.Nil(t, changes)
1081 } else {
1082 require.NoError(t, err)
1083 require.Len(t, changes, len(tc.changes))
1084 for i := range tc.changes {
1085 require.Len(t, changes[i].Commits, len(tc.changes[i].Commits), "changes[%d].Commits", i)
1086 require.Equal(t, tc.changes[i].Path, changes[i].Path, "changes[%d].Path", i)
1087 require.Equal(t, tc.changes[i].Body, changes[i].Body, "changes[%d].Body", i)
1088 }
1089 }
1090 })
1091 }
1092}
1093
1094// Path filter excludes a file, so it should not appear in results.
1095func TestChangesPathFilterExclusion(t *testing.T) {
1096 slog.SetDefault(slogt.New(t))
1097
1098 mock := func(args ...string) ([]byte, error) {
1099 if args[0] == "log" {
1100 return []byte("abc123\nM\tfile.txt\nM\texcluded.txt\n"), nil
1101 }
1102 if args[0] == "ls-tree" {
1103 return []byte("100644 blob abc123def456\t" + args[2] + "\n"), nil
1104 }
1105 if args[0] == "cat-file" {
1106 return []byte("content"), nil
1107 }
1108 if args[0] == "diff" {
1109 return nil, nil
1110 }
1111 return nil, nil
1112 }
1113
1114 exclude := []*regexp.Regexp{regexp.MustCompile(`^excluded\.txt$`)}
1115 filter := git.NewPathFilter(nil, exclude, nil)
1116 changes, err := git.Changes(mock, "main", filter)
1117 require.NoError(t, err)
1118 require.Len(t, changes, 1)
1119 require.Equal(t, "file.txt", changes[0].Path.After.Name)
1120}
1121
1122// Directory path in git log output is skipped.
1123func TestChangesSkipsDirectoryPath(t *testing.T) {
1124 slog.SetDefault(slogt.New(t))
1125
1126 dir := t.TempDir()
1127
1128 mock := func(args ...string) ([]byte, error) {
1129 if args[0] == "log" {
1130 return []byte("abc123\nM\t" + dir + "\n"), nil
1131 }
1132 return nil, nil
1133 }
1134
1135 changes, err := git.Changes(mock, "main", git.NewPathFilter(nil, nil, nil))
1136 require.NoError(t, err)
1137 require.Empty(t, changes)
1138}
1139
1140func TestLineNumberString(t *testing.T) {
1141 type testCaseT struct {
1142 title string
1143 expected string
1144 ln git.LineNumber
1145 }
1146
1147 testCases := []testCaseT{
1148 {
1149 title: "added line",
1150 ln: git.LineNumber{Before: 0, After: 5, Modified: false},
1151 expected: "+5",
1152 },
1153 {
1154 title: "deleted line",
1155 ln: git.LineNumber{Before: 3, After: 0, Modified: false},
1156 expected: "-3",
1157 },
1158 {
1159 title: "same before and after",
1160 ln: git.LineNumber{Before: 7, After: 7, Modified: false},
1161 expected: "7",
1162 },
1163 {
1164 title: "different before and after",
1165 ln: git.LineNumber{Before: 4, After: 9, Modified: false},
1166 expected: "4->9",
1167 },
1168 }
1169
1170 for _, tc := range testCases {
1171 t.Run(tc.title, func(t *testing.T) {
1172 require.Equal(t, tc.expected, tc.ln.String())
1173 })
1174 }
1175}
1176
1177func TestHasAfter(t *testing.T) {
1178 type testCaseT struct {
1179 title string
1180 lns git.LineNumbers
1181 line int
1182 expected bool
1183 }
1184
1185 testCases := []testCaseT{
1186 {
1187 title: "match found",
1188 lns: git.LineNumbers{
1189 {Before: 1, After: 2, Modified: true},
1190 {Before: 3, After: 5, Modified: true},
1191 },
1192 line: 5,
1193 expected: true,
1194 },
1195 {
1196 title: "no match",
1197 lns: git.LineNumbers{
1198 {Before: 1, After: 2, Modified: true},
1199 {Before: 3, After: 4, Modified: true},
1200 },
1201 line: 99,
1202 expected: false,
1203 },
1204 {
1205 title: "empty slice",
1206 lns: git.LineNumbers{},
1207 line: 1,
1208 expected: false,
1209 },
1210 {
1211 // Shifted context lines have After set but Modified=false,
1212 // so HasAfter must return false for them.
1213 title: "shifted line not modified",
1214 lns: git.LineNumbers{
1215 {Before: 3, After: 5, Modified: false},
1216 },
1217 line: 5,
1218 expected: false,
1219 },
1220 }
1221
1222 for _, tc := range testCases {
1223 t.Run(tc.title, func(t *testing.T) {
1224 require.Equal(t, tc.expected, tc.lns.HasAfter(tc.line))
1225 })
1226 }
1227}
1228
1229func TestHasBefore(t *testing.T) {
1230 type testCaseT struct {
1231 title string
1232 lns git.LineNumbers
1233 line int
1234 expected bool
1235 }
1236
1237 testCases := []testCaseT{
1238 {
1239 title: "match found",
1240 lns: git.LineNumbers{
1241 {Before: 1, After: 2, Modified: false},
1242 {Before: 3, After: 5, Modified: false},
1243 },
1244 line: 3,
1245 expected: true,
1246 },
1247 {
1248 title: "no match",
1249 lns: git.LineNumbers{
1250 {Before: 1, After: 2, Modified: false},
1251 {Before: 3, After: 4, Modified: false},
1252 },
1253 line: 99,
1254 expected: false,
1255 },
1256 {
1257 title: "empty slice",
1258 lns: git.LineNumbers{},
1259 line: 1,
1260 expected: false,
1261 },
1262 {
1263 title: "additions only, no match",
1264 lns: git.LineNumbers{
1265 {Before: 0, After: 3, Modified: false},
1266 {Before: 0, After: 4, Modified: false},
1267 },
1268 line: 3,
1269 expected: false,
1270 },
1271 }
1272
1273 for _, tc := range testCases {
1274 t.Run(tc.title, func(t *testing.T) {
1275 require.Equal(t, tc.expected, tc.lns.HasBefore(tc.line))
1276 })
1277 }
1278}
1279
1280func TestNearestAfter(t *testing.T) {
1281 type testCaseT struct {
1282 title string
1283 lns git.LineNumbers
1284 target int
1285 expected int
1286 }
1287
1288 testCases := []testCaseT{
1289 {
1290 title: "exact match",
1291 lns: git.LineNumbers{
1292 {Before: 0, After: 5, Modified: true},
1293 {Before: 0, After: 10, Modified: true},
1294 },
1295 target: 10,
1296 expected: 10,
1297 },
1298 {
1299 title: "closer to lower",
1300 lns: git.LineNumbers{
1301 {Before: 0, After: 5, Modified: true},
1302 {Before: 0, After: 20, Modified: true},
1303 },
1304 target: 8,
1305 expected: 5,
1306 },
1307 {
1308 title: "closer to higher",
1309 lns: git.LineNumbers{
1310 {Before: 0, After: 5, Modified: true},
1311 {Before: 0, After: 20, Modified: true},
1312 },
1313 target: 18,
1314 expected: 20,
1315 },
1316 {
1317 title: "target below all",
1318 lns: git.LineNumbers{
1319 {Before: 0, After: 10, Modified: true},
1320 {Before: 0, After: 20, Modified: true},
1321 },
1322 target: 2,
1323 expected: 10,
1324 },
1325 {
1326 title: "target above all",
1327 lns: git.LineNumbers{
1328 {Before: 0, After: 10, Modified: true},
1329 {Before: 0, After: 20, Modified: true},
1330 },
1331 target: 99,
1332 expected: 20,
1333 },
1334 {
1335 title: "skips deletions",
1336 lns: git.LineNumbers{
1337 {Before: 5, After: 0, Modified: false},
1338 {Before: 0, After: 20, Modified: true},
1339 },
1340 target: 3,
1341 expected: 20,
1342 },
1343 {
1344 title: "empty slice",
1345 lns: git.LineNumbers{},
1346 target: 5,
1347 expected: 0,
1348 },
1349 {
1350 title: "all deletions",
1351 lns: git.LineNumbers{
1352 {Before: 3, After: 0, Modified: false},
1353 {Before: 5, After: 0, Modified: false},
1354 },
1355 target: 4,
1356 expected: 0,
1357 },
1358 }
1359
1360 for _, tc := range testCases {
1361 t.Run(tc.title, func(t *testing.T) {
1362 require.Equal(t, tc.expected, tc.lns.NearestAfter(tc.target))
1363 })
1364 }
1365}
1366
1367func TestNearestBefore(t *testing.T) {
1368 type testCaseT struct {
1369 title string
1370 lns git.LineNumbers
1371 target int
1372 expected int
1373 }
1374
1375 testCases := []testCaseT{
1376 {
1377 title: "exact match",
1378 lns: git.LineNumbers{
1379 {Before: 5, After: 0, Modified: false},
1380 {Before: 10, After: 0, Modified: false},
1381 },
1382 target: 10,
1383 expected: 10,
1384 },
1385 {
1386 title: "closer to lower",
1387 lns: git.LineNumbers{
1388 {Before: 5, After: 0, Modified: false},
1389 {Before: 20, After: 0, Modified: false},
1390 },
1391 target: 8,
1392 expected: 5,
1393 },
1394 {
1395 title: "closer to higher",
1396 lns: git.LineNumbers{
1397 {Before: 5, After: 0, Modified: false},
1398 {Before: 20, After: 0, Modified: false},
1399 },
1400 target: 18,
1401 expected: 20,
1402 },
1403 {
1404 title: "target below all",
1405 lns: git.LineNumbers{
1406 {Before: 10, After: 0, Modified: false},
1407 {Before: 20, After: 0, Modified: false},
1408 },
1409 target: 2,
1410 expected: 10,
1411 },
1412 {
1413 title: "skips additions",
1414 lns: git.LineNumbers{
1415 {Before: 0, After: 5, Modified: false},
1416 {Before: 20, After: 0, Modified: false},
1417 },
1418 target: 3,
1419 expected: 20,
1420 },
1421 {
1422 title: "empty slice",
1423 lns: git.LineNumbers{},
1424 target: 5,
1425 expected: 0,
1426 },
1427 {
1428 title: "all additions",
1429 lns: git.LineNumbers{
1430 {Before: 0, After: 3, Modified: false},
1431 {Before: 0, After: 5, Modified: false},
1432 },
1433 target: 4,
1434 expected: 0,
1435 },
1436 }
1437
1438 for _, tc := range testCases {
1439 t.Run(tc.title, func(t *testing.T) {
1440 require.Equal(t, tc.expected, tc.lns.NearestBefore(tc.target))
1441 })
1442 }
1443}
1444
1445func TestBeforeForAfter(t *testing.T) {
1446 type testCaseT struct {
1447 title string
1448 lns git.LineNumbers
1449 line int
1450 expected int
1451 }
1452
1453 testCases := []testCaseT{
1454 {
1455 title: "match found",
1456 lns: git.LineNumbers{
1457 {Before: 10, After: 20, Modified: false},
1458 {Before: 30, After: 40, Modified: false},
1459 },
1460 line: 20,
1461 expected: 10,
1462 },
1463 {
1464 // Line past the last diff entry — not in the map, so return line itself.
1465 title: "line past last entry returns itself",
1466 lns: git.LineNumbers{
1467 {Before: 10, After: 20, Modified: false},
1468 },
1469 line: 99,
1470 expected: 99,
1471 },
1472 {
1473 title: "empty slice returns line itself",
1474 lns: git.LineNumbers{},
1475 line: 5,
1476 expected: 5,
1477 },
1478 {
1479 title: "line before first entry",
1480 lns: git.LineNumbers{
1481 {Before: 10, After: 20, Modified: false},
1482 },
1483 line: 5,
1484 expected: 5,
1485 },
1486 {
1487 // Line between two entries — not in the map, so return line itself.
1488 title: "line between entries",
1489 lns: git.LineNumbers{
1490 {Before: 5, After: 10, Modified: false},
1491 {Before: 25, After: 30, Modified: false},
1492 },
1493 line: 15,
1494 expected: 15,
1495 },
1496 {
1497 // Line not in the map — return line itself regardless of added-only entries.
1498 title: "skips added-only entries",
1499 lns: git.LineNumbers{
1500 {Before: 5, After: 10, Modified: false},
1501 {Before: 0, After: 12, Modified: false},
1502 {Before: 0, After: 13, Modified: false},
1503 },
1504 line: 20,
1505 expected: 20,
1506 },
1507 {
1508 title: "all entries are added",
1509 lns: git.LineNumbers{
1510 {Before: 0, After: 3, Modified: false},
1511 {Before: 0, After: 4, Modified: false},
1512 },
1513 line: 10,
1514 expected: 10,
1515 },
1516 }
1517
1518 for _, tc := range testCases {
1519 t.Run(tc.title, func(t *testing.T) {
1520 require.Equal(t, tc.expected, tc.lns.BeforeForAfter(tc.line))
1521 })
1522 }
1523}
1524
1525func TestMakeLineRangeFromTo(t *testing.T) {
1526 type testCaseT struct {
1527 title string
1528 expected git.LineNumbers
1529 first int
1530 last int
1531 side git.LineRangeSide
1532 }
1533
1534 testCases := []testCaseT{
1535 {
1536 title: "before only",
1537 first: 5,
1538 last: 7,
1539 side: git.LinesBefore,
1540 expected: git.LineNumbers{
1541 {Before: 5, After: 0, Modified: false},
1542 {Before: 6, After: 0, Modified: false},
1543 {Before: 7, After: 0, Modified: false},
1544 },
1545 },
1546 {
1547 title: "after only",
1548 first: 3,
1549 last: 4,
1550 side: git.LinesAfter,
1551 expected: git.LineNumbers{
1552 {Before: 0, After: 3, Modified: true},
1553 {Before: 0, After: 4, Modified: true},
1554 },
1555 },
1556 {
1557 title: "both",
1558 first: 10,
1559 last: 12,
1560 side: git.LinesBoth,
1561 expected: git.LineNumbers{
1562 {Before: 10, After: 10, Modified: false},
1563 {Before: 11, After: 11, Modified: false},
1564 {Before: 12, After: 12, Modified: false},
1565 },
1566 },
1567 {
1568 title: "negative range",
1569 first: 5,
1570 last: 3,
1571 side: git.LinesBoth,
1572 expected: git.LineNumbers{},
1573 },
1574 {
1575 title: "single element",
1576 first: 4,
1577 last: 4,
1578 side: git.LinesAfter,
1579 expected: git.LineNumbers{
1580 {Before: 0, After: 4, Modified: true},
1581 },
1582 },
1583 }
1584
1585 for _, tc := range testCases {
1586 t.Run(tc.title, func(t *testing.T) {
1587 require.Equal(
1588 t, tc.expected,
1589 git.MakeLineRangeFromTo(tc.first, tc.last, tc.side),
1590 )
1591 })
1592 }
1593}
1594