master
  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}