package helpers import ( "slices" "strings" "testing" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/stretchr/testify/assert" ) // A two-file commit diff as it appears (decolorized) in the main view. file1 has // two consecutive deletions (grape, pear) that share a new-file line number; // file2 has two consecutive additions. const twoFileDiff = `diff --git a/file1.go b/file1.go index 1111111..2222222 100644 --- a/file1.go +++ b/file1.go @@ -1,4 +1,2 @@ apple -grape -pear lemon diff --git a/dir/file2.go b/dir/file2.go index 3333333..4444444 100644 --- a/dir/file2.go +++ b/dir/file2.go @@ -10,2 +9,4 @@ func foo() { ctx +added1 +added2 ctx2` func TestParseDiffLineFromBuffer(t *testing.T) { bufferLines := strings.Split(twoFileDiff, "\n") scenarios := []struct { name string targetIdx int expected parsedDiffLine expectOk bool }{ {"file header", 0, parsedDiffLine{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true}, {"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true}, {"context line", 5, parsedDiffLine{Path: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true}, // The two deletions share new-file line 2 but have distinct old-file lines. {"first deletion", 6, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true}, {"second deletion", 7, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true}, // The second file: its path comes from the second "diff --git" section, // and its additions get distinct new-file line numbers. {"first addition", 15, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true}, {"second addition", 16, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true}, {"out of range", 999, parsedDiffLine{}, false}, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx) assert.Equal(t, s.expectOk, ok) if s.expectOk { assert.Equal(t, s.expected, result) } }) } } func TestParseDiffLineFromBufferRename(t *testing.T) { // A rename with no content change has no hunks and no +++/--- lines, so the // path has to come from the "diff --git" line; a rename with a content // change has them, and they carry the new path. pureRename := strings.Split(`diff --git a/old.go b/new.go similarity index 100% rename from old.go rename to new.go`, "\n") result, ok := parseDiffLineFromBuffer(pureRename, 2) assert.True(t, ok) assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result) renameWithModification := strings.Split(`diff --git a/old.go b/new.go similarity index 62% rename from old.go rename to new.go index 1111111..2222222 100644 --- a/old.go +++ b/new.go @@ -1,2 +1,2 @@ apple -grape +kiwi`, "\n") result, ok = parseDiffLineFromBuffer(renameWithModification, 10) assert.True(t, ok) assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result) } func TestParseDiffLineFromBufferDeletedFile(t *testing.T) { // The new path is /dev/null, so the identity comes from the old path. deletedFile := strings.Split(`diff --git a/gone.go b/gone.go deleted file mode 100644 index 1111111..0000000 --- a/gone.go +++ /dev/null @@ -1,2 +0,0 @@ -apple -grape`, "\n") result, ok := parseDiffLineFromBuffer(deletedFile, 7) assert.True(t, ok) assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result) } func TestParseDiffLineFromBufferNotADiff(t *testing.T) { // A rendering with no "diff --git" line can't be parsed, so the caller falls // back rather than acting on the line. bufferLines := []string{"some", "lines", "that", "are not a diff"} _, ok := parseDiffLineFromBuffer(bufferLines, 2) assert.False(t, ok) } func TestParseDiffLineFromBufferGutterMangled(t *testing.T) { // A diff renderer that moves the line numbers into a gutter keeps the diff // and hunk headers but pushes the +/- markers off the start of each body // line, so every line reads as context. The body no longer matches the hunk // header, so we refuse to parse rather than return a confident mis-parse. mangled := strings.Split(`diff --git a/file1.txt b/file1.txt index 1111111..2222222 100644 --- a/file1.txt +++ b/file1.txt @@ -1,5 +1,3 @@ 1 ⋮ 1 │ apple 2 ⋮ │-grape 3 ⋮ │-pear 4 ⋮ 2 │ lemon 5 ⋮ 3 │ mango`, "\n") _, ok := parseDiffLineFromBuffer(mangled, 6) assert.False(t, ok) } func TestParseDiffLineFromBufferReadInPart(t *testing.T) { lines := strings.Split(twoFileDiff, "\n") // A long diff is read a screenful at a time, so the buffer breaks off part way // through a hunk. The lines that did arrive are resolved all the same, since // holding out for the whole hunk would leave the diff on screen with nothing to // act on. cutShort := lines[:len(lines)-1] result, ok := parseDiffLineFromBuffer(cutShort, 15) assert.True(t, ok) assert.Equal(t, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result) // Only the section the buffer breaks off in is read that way. One that another // section follows is all there, so a hunk short of what its header declares means // the rendering restructured the diff, and none of it is resolved. shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...) _, ok = parseDiffLineFromBuffer(shortFirstSection, 5) assert.False(t, ok) } func TestPathFromDiffHeaderField(t *testing.T) { scenarios := []struct { name string field string expected string }{ {"new side", "b/file.go", "file.go"}, {"old side", "a/file.go", "file.go"}, {"a missing file", "/dev/null", "/dev/null"}, // git terminates the field with a tab when the path has a space in it. {"path with a space", "b/with space.go\t", "with space.go"}, // With core.quotePath enabled (the default) every non-ASCII byte is // escaped, and the field is quoted as a whole, prefix included. {"non-ASCII path", `"b/caf\303\251.go"`, "café.go"}, {"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"}, {"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`}, {"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`}, {"path with a tab", `"b/tab\there.go"`, "tab\there.go"}, {"undecodable", `"b/unterminated`, ""}, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field)) }) } } func TestParseDiffLineFromBufferQuotedPath(t *testing.T) { // A rename of a file whose name needs quoting, with a content change: the // path is quoted on the "diff --git" line and on both of the +++/--- lines. renamed := []string{ `diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`, "similarity index 62%", `rename from "caf\303\251 old.go"`, `rename to "caf\303\251 new.go"`, "index 1111111..2222222 100644", "--- \"a/caf\\303\\251 old.go\"\t", "+++ \"b/caf\\303\\251 new.go\"\t", "@@ -1,2 +1,2 @@", " apple", "-grape", "+kiwi", } result, ok := parseDiffLineFromBuffer(renamed, 10) assert.True(t, ok) assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result) // The same rename without a content change has no +++/--- lines, so the path // comes from the "diff --git" line, where both paths are quoted. result, ok = parseDiffLineFromBuffer(renamed[:4], 2) assert.True(t, ok) assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result) } func TestParseAllDiffLinesFromBuffer(t *testing.T) { // Some decoration above the diff, which belongs to no file section: a commit // message and a diffstat, as `git show` renders them. bufferLines := append( []string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""}, strings.Split(twoFileDiff, "\n")..., ) all := parseAllDiffLinesFromBuffer(bufferLines) // The batch parse resolves each file section once, and has to agree with // resolving the lines one at a time. assert.Len(t, all, len(bufferLines)) for i := range bufferLines { parsed, ok := parseDiffLineFromBuffer(bufferLines, i) assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i]) } // The lines above the first file section are left unresolved. for i := range 6 { assert.False(t, all[i].ok) } assert.True(t, all[6].ok) } func TestParseDiffLineMetadata(t *testing.T) { scenarios := []struct { name string payload string expected parsedDiffLine expectOk bool }{ {"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true}, {"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true}, // A deletion carries both numbers; two consecutive deletions share the // new-file line and differ only in the old-file one. {"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true}, {"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true}, // A whole-file deletion has new-file position 0 and the old path. {"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true}, // The path is the last field, so a ';' within it survives. {"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true}, // A renderer may state the path absolutely; the parser keeps it verbatim // and leaves resolving it to the caller. {"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true}, // A file header has no line number; a hunk header carries the new-file // line of the hunk's first line (0 for a whole-file deletion, mirroring // `@@ -1,N +0,0 @@`). {"file header", "1;f;;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader}, true}, {"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true}, {"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true}, // A file header's line number is always empty, but a renderer that fills // it in anyway is taken at its word rather than rejected. {"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true}, {"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false}, {"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false}, {"too few fields", "1;c;1", parsedDiffLine{}, false}, {"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false}, {"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false}, // Only a file header may omit the new-file line; on any other kind the // record is malformed, and rejecting it falls the row back to the diff // text rather than acting on a line number we don't have. {"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false}, {"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false}, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result, ok := parseDiffLineMetadata(s.payload) assert.Equal(t, s.expectOk, ok) if s.expectOk { assert.Equal(t, s.expected, result) } }) } }