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 "invalidOldMode": {
490 Line: "old mode rw\n",
491 Err: true,
492 },
493 "newMode": {
494 Line: "new mode 100755\n",
495 OutputFile: &File{
496 NewMode: os.FileMode(0100755),
497 },
498 },
499 "invalidNewMode": {
500 Line: "new mode rwx\n",
501 Err: true,
502 },
503 "deletedFileMode": {
504 Line: "deleted file mode 100644\n",
505 DefaultName: "dir/file.txt",
506 OutputFile: &File{
507 OldName: "dir/file.txt",
508 OldMode: os.FileMode(0100644),
509 IsDelete: true,
510 },
511 },
512 "newFileMode": {
513 Line: "new file mode 100755\n",
514 DefaultName: "dir/file.txt",
515 OutputFile: &File{
516 NewName: "dir/file.txt",
517 NewMode: os.FileMode(0100755),
518 IsNew: true,
519 },
520 },
521 "copyFrom": {
522 Line: "copy from dir/file.txt\n",
523 OutputFile: &File{
524 OldName: "dir/file.txt",
525 IsCopy: true,
526 },
527 },
528 "copyTo": {
529 Line: "copy to dir/file.txt\n",
530 OutputFile: &File{
531 NewName: "dir/file.txt",
532 IsCopy: true,
533 },
534 },
535 "renameFrom": {
536 Line: "rename from dir/file.txt\n",
537 OutputFile: &File{
538 OldName: "dir/file.txt",
539 IsRename: true,
540 },
541 },
542 "renameTo": {
543 Line: "rename to dir/file.txt\n",
544 OutputFile: &File{
545 NewName: "dir/file.txt",
546 IsRename: true,
547 },
548 },
549 "similarityIndex": {
550 Line: "similarity index 88%\n",
551 OutputFile: &File{
552 Score: 88,
553 },
554 },
555 "similarityIndexTooBig": {
556 Line: "similarity index 9001%\n",
557 OutputFile: &File{
558 Score: 0,
559 },
560 },
561 "similarityIndexInvalid": {
562 Line: "similarity index 12ab%\n",
563 Err: true,
564 },
565 "indexFullSHA1AndMode": {
566 Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14..04fab916d8f938173cbb8b93469855f0e838f098 100644\n",
567 OutputFile: &File{
568 OldOIDPrefix: "79c6d7f7b7e76c75b3d238f12fb1323f2333ba14",
569 NewOIDPrefix: "04fab916d8f938173cbb8b93469855f0e838f098",
570 OldMode: os.FileMode(0100644),
571 },
572 },
573 "indexFullSHA1NoMode": {
574 Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14..04fab916d8f938173cbb8b93469855f0e838f098\n",
575 OutputFile: &File{
576 OldOIDPrefix: "79c6d7f7b7e76c75b3d238f12fb1323f2333ba14",
577 NewOIDPrefix: "04fab916d8f938173cbb8b93469855f0e838f098",
578 },
579 },
580 "indexAbbrevSHA1AndMode": {
581 Line: "index 79c6d7..04fab9 100644\n",
582 OutputFile: &File{
583 OldOIDPrefix: "79c6d7",
584 NewOIDPrefix: "04fab9",
585 OldMode: os.FileMode(0100644),
586 },
587 },
588 "indexInvalid": {
589 Line: "index 79c6d7f7b7e76c75b3d238f12fb1323f2333ba14\n",
590 Err: true,
591 },
592 }
593
594 for name, test := range tests {
595 t.Run(name, func(t *testing.T) {
596 var f File
597 if test.InputFile != nil {
598 f = *test.InputFile
599 }
600
601 end, err := parseGitHeaderData(&f, test.Line, test.DefaultName)
602 if test.Err {
603 if err == nil || err == io.EOF {
604 t.Fatalf("expected error parsing header data, but got %v", err)
605 }
606 return
607 }
608 if err != nil {
609 t.Fatalf("unexpected error parsing header data: %v", err)
610 }
611
612 if test.OutputFile != nil && !reflect.DeepEqual(test.OutputFile, &f) {
613 t.Errorf("incorrect output:\nexpected: %+v\nactual: %+v", test.OutputFile, &f)
614 }
615 if end != test.End {
616 t.Errorf("incorrect end state, expected %t, actual %t", test.End, end)
617 }
618 })
619 }
620}
621
622func TestParseGitHeaderName(t *testing.T) {
623 tests := map[string]struct {
624 Input string
625 Output string
626 Err bool
627 }{
628 "twoMatchingNames": {
629 Input: "a/dir/file.txt b/dir/file.txt",
630 Output: "dir/file.txt",
631 },
632 "twoDifferentNames": {
633 Input: "a/dir/foo.txt b/dir/bar.txt",
634 Output: "",
635 },
636 "matchingNamesWithSpaces": {
637 Input: "a/dir/file with spaces.txt b/dir/file with spaces.txt",
638 Output: "dir/file with spaces.txt",
639 },
640 "matchingNamesWithTrailingSpaces": {
641 Input: "a/dir/spaces b/dir/spaces ",
642 Output: "dir/spaces ",
643 },
644 "matchingNamesQuoted": {
645 Input: `"a/dir/\"quotes\".txt" "b/dir/\"quotes\".txt"`,
646 Output: `dir/"quotes".txt`,
647 },
648 "matchingNamesFirstQuoted": {
649 Input: `"a/dir/file.txt" b/dir/file.txt`,
650 Output: "dir/file.txt",
651 },
652 "matchingNamesSecondQuoted": {
653 Input: `a/dir/file.txt "b/dir/file.txt"`,
654 Output: "dir/file.txt",
655 },
656 "noSecondName": {
657 Input: "a/dir/foo.txt",
658 Output: "",
659 },
660 "noSecondNameQuoted": {
661 Input: `"a/dir/foo.txt"`,
662 Output: "",
663 },
664 "invalidName": {
665 Input: `"a/dir/file.txt b/dir/file.txt`,
666 Err: true,
667 },
668 }
669
670 for name, test := range tests {
671 t.Run(name, func(t *testing.T) {
672 output, err := parseGitHeaderName(test.Input)
673 if test.Err {
674 if err == nil {
675 t.Fatalf("expected error parsing header name, but got nil")
676 }
677 return
678 }
679 if err != nil {
680 t.Fatalf("unexpected error parsing header name: %v", err)
681 }
682
683 if output != test.Output {
684 t.Errorf("incorrect output: expected %q, actual %q", test.Output, output)
685 }
686 })
687 }
688}
689
690func TestHasEpochTimestamp(t *testing.T) {
691 tests := map[string]struct {
692 Input string
693 Output bool
694 }{
695 "utcTimestamp": {
696 Input: "+++ file.txt\t1970-01-01 00:00:00 +0000\n",
697 Output: true,
698 },
699 "utcZoneWithColon": {
700 Input: "+++ file.txt\t1970-01-01 00:00:00 +00:00\n",
701 Output: true,
702 },
703 "utcZoneWithMilliseconds": {
704 Input: "+++ file.txt\t1970-01-01 00:00:00.000000 +00:00\n",
705 Output: true,
706 },
707 "westTimestamp": {
708 Input: "+++ file.txt\t1969-12-31 16:00:00 -0800\n",
709 Output: true,
710 },
711 "eastTimestamp": {
712 Input: "+++ file.txt\t1970-01-01 04:00:00 +0400\n",
713 Output: true,
714 },
715 "noTab": {
716 Input: "+++ file.txt 1970-01-01 00:00:00 +0000\n",
717 Output: false,
718 },
719 "invalidFormat": {
720 Input: "+++ file.txt\t1970-01-01T00:00:00Z\n",
721 Output: false,
722 },
723 "notEpoch": {
724 Input: "+++ file.txt\t2019-03-21 12:34:56.789 -0700\n",
725 Output: false,
726 },
727 "notTimestamp": {
728 Input: "+++ file.txt\trandom text\n",
729 Output: false,
730 },
731 "notTimestampShort": {
732 Input: "+++ file.txt\t0\n",
733 Output: false,
734 },
735 }
736
737 for name, test := range tests {
738 t.Run(name, func(t *testing.T) {
739 output := hasEpochTimestamp(test.Input)
740 if output != test.Output {
741 t.Errorf("incorrect output: expected %t, actual %t", test.Output, output)
742 }
743 })
744 }
745}