fork of go-gitdiff with jj support
1package gitdiff
2
3import (
4 "io"
5 "os"
6 "reflect"
7 "testing"
8)
9
10func TestParseGitFileHeader(t *testing.T) {
11 tests := map[string]struct {
12 Input string
13 Output *File
14 Err bool
15 }{
16 "fileContentChange": {
17 Input: `diff --git a/dir/file.txt b/dir/file.txt
18index 1c23fcc..40a1b33 100644
19--- a/dir/file.txt
20+++ b/dir/file.txt
21@@ -2,3 +4,5 @@
22`,
23 Output: &File{
24 OldName: "dir/file.txt",
25 NewName: "dir/file.txt",
26 OldMode: os.FileMode(0100644),
27 OldOIDPrefix: "1c23fcc",
28 NewOIDPrefix: "40a1b33",
29 },
30 },
31 "newFile": {
32 Input: `diff --git a/dir/file.txt b/dir/file.txt
33new file mode 100644
34index 0000000..f5711e4
35--- /dev/null
36+++ b/dir/file.txt
37`,
38 Output: &File{
39 NewName: "dir/file.txt",
40 NewMode: os.FileMode(0100644),
41 OldOIDPrefix: "0000000",
42 NewOIDPrefix: "f5711e4",
43 IsNew: true,
44 },
45 },
46 "newEmptyFile": {
47 Input: `diff --git a/empty.txt b/empty.txt
48new file mode 100644
49index 0000000..e69de29
50`,
51 Output: &File{
52 NewName: "empty.txt",
53 NewMode: os.FileMode(0100644),
54 OldOIDPrefix: "0000000",
55 NewOIDPrefix: "e69de29",
56 IsNew: true,
57 },
58 },
59 "deleteFile": {
60 Input: `diff --git a/dir/file.txt b/dir/file.txt
61deleted file mode 100644
62index 44cc321..0000000
63--- a/dir/file.txt
64+++ /dev/null
65`,
66 Output: &File{
67 OldName: "dir/file.txt",
68 OldMode: os.FileMode(0100644),
69 OldOIDPrefix: "44cc321",
70 NewOIDPrefix: "0000000",
71 IsDelete: true,
72 },
73 },
74 "changeMode": {
75 Input: `diff --git a/file.sh b/file.sh
76old mode 100644
77new mode 100755
78`,
79 Output: &File{
80 OldName: "file.sh",
81 NewName: "file.sh",
82 OldMode: os.FileMode(0100644),
83 NewMode: os.FileMode(0100755),
84 },
85 },
86 "rename": {
87 Input: `diff --git a/foo.txt b/bar.txt
88similarity index 100%
89rename from foo.txt
90rename to bar.txt
91`,
92 Output: &File{
93 OldName: "foo.txt",
94 NewName: "bar.txt",
95 Score: 100,
96 IsRename: true,
97 },
98 },
99 "copy": {
100 Input: `diff --git a/file.txt b/copy.txt
101similarity index 100%
102copy from file.txt
103copy to copy.txt
104`,
105 Output: &File{
106 OldName: "file.txt",
107 NewName: "copy.txt",
108 Score: 100,
109 IsCopy: true,
110 },
111 },
112 "missingDefaultFilename": {
113 Input: `diff --git a/foo.sh b/bar.sh
114old mode 100644
115new mode 100755
116`,
117 Err: true,
118 },
119 "missingNewFilename": {
120 Input: `diff --git a/file.txt b/file.txt
121index 1c23fcc..40a1b33 100644
122--- a/file.txt
123`,
124 Err: true,
125 },
126 "missingOldFilename": {
127 Input: `diff --git a/file.txt b/file.txt
128index 1c23fcc..40a1b33 100644
129+++ b/file.txt
130`,
131 Err: true,
132 },
133 "invalidHeaderLine": {
134 Input: `diff --git a/file.txt b/file.txt
135index deadbeef
136--- a/file.txt
137+++ b/file.txt
138`,
139 Err: true,
140 },
141 "notGitHeader": {
142 Input: `--- file.txt
143+++ file.txt
144@@ -0,0 +1 @@
145`,
146 Output: nil,
147 },
148 }
149
150 for name, test := range tests {
151 t.Run(name, func(t *testing.T) {
152 p := newTestParser(test.Input, true)
153
154 f, err := p.ParseGitFileHeader()
155 if test.Err {
156 if err == nil || err == io.EOF {
157 t.Fatalf("expected error parsing git file header, got %v", err)
158 }
159 return
160 }
161 if err != nil {
162 t.Fatalf("unexpected error parsing git file header: %v", err)
163 }
164
165 if !reflect.DeepEqual(test.Output, f) {
166 t.Errorf("incorrect file\nexpected: %+v\n actual: %+v", test.Output, f)
167 }
168 })
169 }
170}
171
172func TestParseTraditionalFileHeader(t *testing.T) {
173 tests := map[string]struct {
174 Input string
175 Output *File
176 Err bool
177 }{
178 "fileContentChange": {
179 Input: `--- dir/file_old.txt 2019-03-21 23:00:00.0 -0700
180+++ dir/file_new.txt 2019-03-21 23:30:00.0 -0700
181@@ -0,0 +1 @@
182`,
183 Output: &File{
184 OldName: "dir/file_new.txt",
185 NewName: "dir/file_new.txt",
186 },
187 },
188 "newFile": {
189 Input: `--- /dev/null 1969-12-31 17:00:00.0 -0700
190+++ dir/file.txt 2019-03-21 23:30:00.0 -0700
191@@ -0,0 +1 @@
192`,
193 Output: &File{
194 NewName: "dir/file.txt",
195 IsNew: true,
196 },
197 },
198 "newFileTimestamp": {
199 Input: `--- dir/file.txt 1969-12-31 17:00:00.0 -0700
200+++ dir/file.txt 2019-03-21 23:30:00.0 -0700
201@@ -0,0 +1 @@
202`,
203 Output: &File{
204 NewName: "dir/file.txt",
205 IsNew: true,
206 },
207 },
208 "deleteFile": {
209 Input: `--- dir/file.txt 2019-03-21 23:30:00.0 -0700
210+++ /dev/null 1969-12-31 17:00:00.0 -0700
211@@ -0,0 +1 @@
212`,
213 Output: &File{
214 OldName: "dir/file.txt",
215 IsDelete: true,
216 },
217 },
218 "deleteFileTimestamp": {
219 Input: `--- dir/file.txt 2019-03-21 23:30:00.0 -0700
220+++ dir/file.txt 1969-12-31 17:00:00.0 -0700
221@@ -0,0 +1 @@
222`,
223 Output: &File{
224 OldName: "dir/file.txt",
225 IsDelete: true,
226 },
227 },
228 "useShortestPrefixName": {
229 Input: `--- dir/file.txt 2019-03-21 23:00:00.0 -0700
230+++ dir/file.txt~ 2019-03-21 23:30:00.0 -0700
231@@ -0,0 +1 @@
232`,
233 Output: &File{
234 OldName: "dir/file.txt",
235 NewName: "dir/file.txt",
236 },
237 },
238 "notTraditionalHeader": {
239 Input: `diff --git a/dir/file.txt b/dir/file.txt
240--- a/dir/file.txt
241+++ b/dir/file.txt
242`,
243 Output: nil,
244 },
245 "noUnifiedFragment": {
246 Input: `--- dir/file_old.txt 2019-03-21 23:00:00.0 -0700
247+++ dir/file_new.txt 2019-03-21 23:30:00.0 -0700
248context line
249+added line
250`,
251 Output: nil,
252 },
253 }
254
255 for name, test := range tests {
256 t.Run(name, func(t *testing.T) {
257 p := newTestParser(test.Input, true)
258
259 f, err := p.ParseTraditionalFileHeader()
260 if test.Err {
261 if err == nil || err == io.EOF {
262 t.Fatalf("expected error parsing traditional file header, got %v", err)
263 }
264 return
265 }
266 if err != nil {
267 t.Fatalf("unexpected error parsing traditional file header: %v", err)
268 }
269
270 if !reflect.DeepEqual(test.Output, f) {
271 t.Errorf("incorrect file\nexpected: %+v\n actual: %+v", test.Output, f)
272 }
273 })
274 }
275}
276
277func TestCleanName(t *testing.T) {
278 tests := map[string]struct {
279 Input string
280 Drop int
281 Output string
282 }{
283 "alreadyClean": {
284 Input: "a/b/c.txt", Output: "a/b/c.txt",
285 },
286 "doubleSlashes": {
287 Input: "a//b/c.txt", Output: "a/b/c.txt",
288 },
289 "tripleSlashes": {
290 Input: "a///b/c.txt", Output: "a/b/c.txt",
291 },
292 "dropPrefix": {
293 Input: "a/b/c.txt", Drop: 2, Output: "c.txt",
294 },
295 "removeDoublesBeforeDrop": {
296 Input: "a//b/c.txt", Drop: 1, Output: "b/c.txt",
297 },
298 }
299
300 for name, test := range tests {
301 t.Run(name, func(t *testing.T) {
302 output := cleanName(test.Input, test.Drop)
303 if output != test.Output {
304 t.Fatalf("incorrect output: expected %q, actual %q", test.Output, output)
305 }
306 })
307 }
308}
309
310func TestParseName(t *testing.T) {
311 tests := map[string]struct {
312 Input string
313 Term byte
314 Drop int
315 Output string
316 N int
317 Err bool
318 }{
319 "singleUnquoted": {
320 Input: "dir/file.txt", Output: "dir/file.txt", N: 12,
321 },
322 "singleQuoted": {
323 Input: `"dir/file.txt"`, Output: "dir/file.txt", N: 14,
324 },
325 "quotedWithEscape": {
326 Input: `"dir/\"quotes\".txt"`, Output: `dir/"quotes".txt`, N: 20,
327 },
328 "quotedWithSpaces": {
329 Input: `"dir/space file.txt"`, Output: "dir/space file.txt", N: 20,
330 },
331 "tabTerminator": {
332 Input: "dir/space file.txt\tfile2.txt", Term: '\t', Output: "dir/space file.txt", N: 18,
333 },
334 "dropPrefix": {
335 Input: "a/dir/file.txt", Drop: 1, Output: "dir/file.txt", N: 14,
336 },
337 "unquotedWithSpaces": {
338 Input: "dir/with spaces.txt", Output: "dir/with spaces.txt", N: 19,
339 },
340 "unquotedWithTrailingSpaces": {
341 Input: "dir/with spaces.space ", Output: "dir/with spaces.space ", N: 23,
342 },
343 "devNull": {
344 Input: "/dev/null", Term: '\t', Drop: 1, Output: "/dev/null", N: 9,
345 },
346 "newlineSeparates": {
347 Input: "dir/file.txt\n", Output: "dir/file.txt", N: 12,
348 },
349 "emptyString": {
350 Input: "", Err: true,
351 },
352 "emptyQuotedString": {
353 Input: `""`, Err: true,
354 },
355 "unterminatedQuotes": {
356 Input: `"dir/file.txt`, Err: true,
357 },
358 }
359
360 for name, test := range tests {
361 t.Run(name, func(t *testing.T) {
362 output, n, err := parseName(test.Input, test.Term, test.Drop)
363 if test.Err {
364 if err == nil || err == io.EOF {
365 t.Fatalf("expected error parsing name, but got %v", err)
366 }
367 return
368 }
369 if err != nil {
370 t.Fatalf("unexpected error parsing name: %v", err)
371 }
372
373 if output != test.Output {
374 t.Errorf("incorrect output: expected %q, actual: %q", test.Output, output)
375 }
376 if n != test.N {
377 t.Errorf("incorrect next position: expected %d, actual %d", test.N, n)
378 }
379 })
380 }
381}
382
383func TestParseGitHeaderData(t *testing.T) {
384 tests := map[string]struct {
385 InputFile *File
386 Line string
387 DefaultName string
388
389 OutputFile *File
390 End bool
391 Err bool
392 }{
393 "fragementEndsParsing": {
394 Line: "@@ -12,3 +12,2 @@\n",
395 End: true,
396 },
397 "unknownEndsParsing": {
398 Line: "GIT binary file\n",
399 End: true,
400 },
401 "oldFileName": {
402 Line: "--- a/dir/file.txt\n",
403 OutputFile: &File{
404 OldName: "dir/file.txt",
405 },
406 },
407 "oldFileNameDevNull": {
408 InputFile: &File{
409 IsNew: true,
410 },
411 Line: "--- /dev/null\n",
412 OutputFile: &File{
413 IsNew: true,
414 },
415 },
416 "oldFileNameInconsistent": {
417 InputFile: &File{
418 OldName: "dir/foo.txt",
419 },
420 Line: "--- a/dir/bar.txt\n",
421 Err: true,
422 },
423 "oldFileNameExistingCreateMismatch": {
424 InputFile: &File{
425 OldName: "dir/foo.txt",
426 IsNew: true,
427 },
428 Line: "--- /dev/null\n",
429 Err: true,
430 },
431 "oldFileNameParsedCreateMismatch": {
432 InputFile: &File{
433 IsNew: true,
434 },
435 Line: "--- a/dir/file.txt\n",
436 Err: true,
437 },
438 "oldFileNameMissing": {
439 Line: "--- \n",
440 Err: true,
441 },
442 "newFileName": {
443 Line: "+++ b/dir/file.txt\n",
444 OutputFile: &File{
445 NewName: "dir/file.txt",
446 },
447 },
448 "newFileNameDevNull": {
449 InputFile: &File{
450 IsDelete: true,
451 },
452 Line: "+++ /dev/null\n",
453 OutputFile: &File{
454 IsDelete: true,
455 },
456 },
457 "newFileNameInconsistent": {
458 InputFile: &File{
459 NewName: "dir/foo.txt",
460 },
461 Line: "+++ b/dir/bar.txt\n",
462 Err: true,
463 },
464 "newFileNameExistingDeleteMismatch": {
465 InputFile: &File{
466 NewName: "dir/foo.txt",
467 IsDelete: true,
468 },
469 Line: "+++ /dev/null\n",
470 Err: true,
471 },
472 "newFileNameParsedDeleteMismatch": {
473 InputFile: &File{
474 IsDelete: true,
475 },
476 Line: "+++ b/dir/file.txt\n",
477 Err: true,
478 },
479 "newFileNameMissing": {
480 Line: "+++ \n",
481 Err: true,
482 },
483 "oldMode": {
484 Line: "old mode 100644\n",
485 OutputFile: &File{
486 OldMode: os.FileMode(0100644),
487 },
488 },
489 "oldModeWithTrailingSpace": {
490 Line: "old mode 100644\r\n",
491 OutputFile: &File{
492 OldMode: os.FileMode(0100644),
493 },
494 },
495 "invalidOldMode": {
496 Line: "old mode rw\n",
497 Err: true,
498 },
499 "newMode": {
500 Line: "new mode 100755\n",
501 OutputFile: &File{
502 NewMode: os.FileMode(0100755),
503 },
504 },
505 "newModeWithTrailingSpace": {
506 Line: "new mode 100755\r\n",
507 OutputFile: &File{
508 NewMode: os.FileMode(0100755),
509 },
510 },
511 "invalidNewMode": {
512 Line: "new mode rwx\n",
513 Err: true,
514 },
515 "deletedFileMode": {
516 Line: "deleted file mode 100644\n",
517 DefaultName: "dir/file.txt",
518 OutputFile: &File{
519 OldName: "dir/file.txt",
520 OldMode: os.FileMode(0100644),
521 IsDelete: true,
522 },
523 },
524 "newFileMode": {
525 Line: "new file mode 100755\n",
526 DefaultName: "dir/file.txt",
527 OutputFile: &File{
528 NewName: "dir/file.txt",
529 NewMode: os.FileMode(0100755),
530 IsNew: true,
531 },
532 },
533 "newFileModeWithTrailingSpace": {
534 Line: "new file mode 100755\r\n",
535 DefaultName: "dir/file.txt",
536 OutputFile: &File{
537 NewName: "dir/file.txt",
538 NewMode: os.FileMode(0100755),
539 IsNew: true,
540 },
541 },
542 "copyFrom": {
543 Line: "copy from dir/file.txt\n",
544 OutputFile: &File{
545 OldName: "dir/file.txt",
546 IsCopy: true,
547 },
548 },
549 "copyTo": {
550 Line: "copy to dir/file.txt\n",
551 OutputFile: &File{
552 NewName: "dir/file.txt",
553 IsCopy: true,
554 },
555 },
556 "renameFrom": {
557 Line: "rename from dir/file.txt\n",
558 OutputFile: &File{
559 OldName: "dir/file.txt",
560 IsRename: true,
561 },
562 },
563 "renameTo": {
564 Line: "rename to dir/file.txt\n",
565 OutputFile: &File{
566 NewName: "dir/file.txt",
567 IsRename: true,
568 },
569 },
570 "similarityIndex": {
571 Line: "similarity index 88%\n",
572 OutputFile: &File{
573 Score: 88,
574 },
575 },
576 "similarityIndexTooBig": {
577 Line: "similarity index 9001%\n",
578 OutputFile: &File{
579 Score: 0,
580 },
581 },
582 "similarityIndexInvalid": {
583 Line: "similarity index 12ab%\n",
584 Err: true,
585 },
586 "indexFullSHA1AndMode": {
587 Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14..04fab916d8f938173cbb8b93469855f0e838f098 100644\n",
588 OutputFile: &File{
589 OldOIDPrefix: "79c6d7f7b7e76c75b3d238f12fb1323f2333ba14",
590 NewOIDPrefix: "04fab916d8f938173cbb8b93469855f0e838f098",
591 OldMode: os.FileMode(0100644),
592 },
593 },
594 "indexFullSHA1NoMode": {
595 Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14..04fab916d8f938173cbb8b93469855f0e838f098\n",
596 OutputFile: &File{
597 OldOIDPrefix: "79c6d7f7b7e76c75b3d238f12fb1323f2333ba14",
598 NewOIDPrefix: "04fab916d8f938173cbb8b93469855f0e838f098",
599 },
600 },
601 "indexAbbrevSHA1AndMode": {
602 Line: "index 79c6d7..04fab9 100644\n",
603 OutputFile: &File{
604 OldOIDPrefix: "79c6d7",
605 NewOIDPrefix: "04fab9",
606 OldMode: os.FileMode(0100644),
607 },
608 },
609 "indexInvalid": {
610 Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14\n",
611 Err: true,
612 },
613 }
614
615 for name, test := range tests {
616 t.Run(name, func(t *testing.T) {
617 var f File
618 if test.InputFile != nil {
619 f = *test.InputFile
620 }
621
622 end, err := parseGitHeaderData(&f, test.Line, test.DefaultName)
623 if test.Err {
624 if err == nil || err == io.EOF {
625 t.Fatalf("expected error parsing header data, but got %v", err)
626 }
627 return
628 }
629 if err != nil {
630 t.Fatalf("unexpected error parsing header data: %v", err)
631 }
632
633 if test.OutputFile != nil && !reflect.DeepEqual(test.OutputFile, &f) {
634 t.Errorf("incorrect output:\nexpected: %+v\nactual: %+v", test.OutputFile, &f)
635 }
636 if end != test.End {
637 t.Errorf("incorrect end state, expected %t, actual %t", test.End, end)
638 }
639 })
640 }
641}
642
643func TestParseGitHeaderName(t *testing.T) {
644 tests := map[string]struct {
645 Input string
646 Output string
647 Err bool
648 }{
649 "twoMatchingNames": {
650 Input: "a/dir/file.txt b/dir/file.txt",
651 Output: "dir/file.txt",
652 },
653 "twoDifferentNames": {
654 Input: "a/dir/foo.txt b/dir/bar.txt",
655 Output: "",
656 },
657 "matchingNamesWithSpaces": {
658 Input: "a/dir/file with spaces.txt b/dir/file with spaces.txt",
659 Output: "dir/file with spaces.txt",
660 },
661 "matchingNamesWithTrailingSpaces": {
662 Input: "a/dir/spaces b/dir/spaces ",
663 Output: "dir/spaces ",
664 },
665 "matchingNamesQuoted": {
666 Input: `"a/dir/\"quotes\".txt" "b/dir/\"quotes\".txt"`,
667 Output: `dir/"quotes".txt`,
668 },
669 "matchingNamesFirstQuoted": {
670 Input: `"a/dir/file.txt" b/dir/file.txt`,
671 Output: "dir/file.txt",
672 },
673 "matchingNamesSecondQuoted": {
674 Input: `a/dir/file.txt "b/dir/file.txt"`,
675 Output: "dir/file.txt",
676 },
677 "noSecondName": {
678 Input: "a/dir/foo.txt",
679 Output: "",
680 },
681 "noSecondNameQuoted": {
682 Input: `"a/dir/foo.txt"`,
683 Output: "",
684 },
685 "invalidName": {
686 Input: `"a/dir/file.txt b/dir/file.txt`,
687 Err: true,
688 },
689 }
690
691 for name, test := range tests {
692 t.Run(name, func(t *testing.T) {
693 output, err := parseGitHeaderName(test.Input)
694 if test.Err {
695 if err == nil {
696 t.Fatalf("expected error parsing header name, but got nil")
697 }
698 return
699 }
700 if err != nil {
701 t.Fatalf("unexpected error parsing header name: %v", err)
702 }
703
704 if output != test.Output {
705 t.Errorf("incorrect output: expected %q, actual %q", test.Output, output)
706 }
707 })
708 }
709}
710
711func TestHasEpochTimestamp(t *testing.T) {
712 tests := map[string]struct {
713 Input string
714 Output bool
715 }{
716 "utcTimestamp": {
717 Input: "+++ file.txt\t1970-01-01 00:00:00 +0000\n",
718 Output: true,
719 },
720 "utcZoneWithColon": {
721 Input: "+++ file.txt\t1970-01-01 00:00:00 +00:00\n",
722 Output: true,
723 },
724 "utcZoneWithMilliseconds": {
725 Input: "+++ file.txt\t1970-01-01 00:00:00.000000 +00:00\n",
726 Output: true,
727 },
728 "westTimestamp": {
729 Input: "+++ file.txt\t1969-12-31 16:00:00 -0800\n",
730 Output: true,
731 },
732 "eastTimestamp": {
733 Input: "+++ file.txt\t1970-01-01 04:00:00 +0400\n",
734 Output: true,
735 },
736 "noTab": {
737 Input: "+++ file.txt 1970-01-01 00:00:00 +0000\n",
738 Output: false,
739 },
740 "invalidFormat": {
741 Input: "+++ file.txt\t1970-01-01T00:00:00Z\n",
742 Output: false,
743 },
744 "notEpoch": {
745 Input: "+++ file.txt\t2019-03-21 12:34:56.789 -0700\n",
746 Output: false,
747 },
748 "notTimestamp": {
749 Input: "+++ file.txt\trandom text\n",
750 Output: false,
751 },
752 "notTimestampShort": {
753 Input: "+++ file.txt\t0\n",
754 Output: false,
755 },
756 }
757
758 for name, test := range tests {
759 t.Run(name, func(t *testing.T) {
760 output := hasEpochTimestamp(test.Input)
761 if output != test.Output {
762 t.Errorf("incorrect output: expected %t, actual %t", test.Output, output)
763 }
764 })
765 }
766}