mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-06 05:57:38 -04:00
Recover the identity of a diff line from the rendered diff
To act on the line the user is pointing at in a diff view — to stage it, to open it in an editor, to keep the cursor on it while the diff is regenerated — we need to know which file it belongs to and where it sits in that file. Only the diff the view was rendered from knows that, and by the time it's on screen all we have is text. So parse it back: the view's contents are (usually) a unified diff, and running them through the patch parser recovers each row's file, kind and line numbers. "Usually" is why this goes behind a seam, and why it answers "I don't know" rather than guessing: a diff renderer is free to restructure what it prints, and a wrong answer here means acting on the wrong line of the wrong file. Parsing a whole buffer is a separate entry point from parsing a single line, because a caller resolving every row of a large diff must not re-parse a file's section once per line of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a250f59dad
commit
3a6295252e
@@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
searchHelper,
|
||||
)
|
||||
diffHelper := helpers.NewDiffHelper(helperCommon)
|
||||
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
|
||||
cherryPickHelper := helpers.NewCherryPickHelper(
|
||||
helperCommon,
|
||||
rebaseHelper,
|
||||
@@ -110,6 +111,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
|
||||
Snake: helpers.NewSnakeHelper(helperCommon),
|
||||
Diff: diffHelper,
|
||||
DiffLine: diffLineHelper,
|
||||
Repos: reposHelper,
|
||||
RecordDirectory: recordDirectoryHelper,
|
||||
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
type DiffLineHelper struct {
|
||||
c *HelperCommon
|
||||
}
|
||||
|
||||
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
|
||||
return &DiffLineHelper{c: c}
|
||||
}
|
||||
|
||||
// GetDiffLineInfo recovers the identity — file, kind, and old/new line number —
|
||||
// of the diff row at the given (wrapped) view line of the given view. It is the
|
||||
// seam every consumer of a diff row goes through, so that how we recover that
|
||||
// identity can change without them noticing: today the only way is to parse the
|
||||
// view's contents as a unified diff, which works for the renderings that keep a
|
||||
// diff's structure (no renderer, `git diff --color`, a renderer that only
|
||||
// colorizes) and fails for the ones that restructure it.
|
||||
//
|
||||
// ok is false when the row's identity can't be recovered, in which case the
|
||||
// caller must not act on the line at all.
|
||||
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
// The cursor and clicks land on a view line, which counts wrapped segments;
|
||||
// the contents are indexed by unwrapped buffer line.
|
||||
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
// The lines as written, not as shown: git ends the path field of a diff header
|
||||
// with a tab when the path contains a space, and the view shows a tab as spaces.
|
||||
parsed, ok := parseDiffLineFromBuffer(view.LinesAsWritten(), bufferLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
return self.diffLineInfoFromParsed(parsed), true
|
||||
}
|
||||
|
||||
// diffLineInfoFromParsed turns the parser's repo-relative result into the
|
||||
// absolute-path identity consumers work with.
|
||||
func (self *DiffLineHelper) diffLineInfoFromParsed(parsed parsedDiffLine) types.DiffLineInfo {
|
||||
return types.DiffLineInfo{
|
||||
Path: filepath.Join(self.c.Git().RepoPaths.WorktreePath(), parsed.RelPath),
|
||||
Type: parsed.Type,
|
||||
NewLine: parsed.NewLine,
|
||||
OldLine: parsed.OldLine,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
|
||||
// unified diff.
|
||||
const diffFilePrefix = "diff --git "
|
||||
|
||||
// submodulePrefix opens the line a submodule's section of a diff starts with, which
|
||||
// stands in for the "diff --git" header a file of the repo gets.
|
||||
const submodulePrefix = "Submodule "
|
||||
|
||||
// submoduleSectionPattern matches that line and captures the submodule's path. git
|
||||
// writes one of two kinds: the commit the submodule is checked out at has moved
|
||||
// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is
|
||||
// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in
|
||||
// place of the colon where the two commits can't both be read), or its working tree
|
||||
// is dirty ("Submodule sub contains untracked content").
|
||||
//
|
||||
// The path is captured greedily: git writes it unquoted, so a path that itself ends
|
||||
// in something reading like a range of commits is told apart by taking the last such
|
||||
// range on the line.
|
||||
var submoduleSectionPattern = regexp.MustCompile(
|
||||
`^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`)
|
||||
|
||||
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
|
||||
// RelPath is the path as the diff header spells it, i.e. relative to the repo
|
||||
// root; the caller turns it into the absolute path of types.DiffLineInfo.
|
||||
type parsedDiffLine struct {
|
||||
RelPath string
|
||||
Type types.DiffLineType
|
||||
NewLine int
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// bufferLineParse is the parser's result for one buffer line: the recovered
|
||||
// identity, and whether the line could be resolved at all (false for a line in
|
||||
// an unparseable section, or outside any file section).
|
||||
type bufferLineParse struct {
|
||||
parsed parsedDiffLine
|
||||
ok bool
|
||||
}
|
||||
|
||||
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
|
||||
// parsing the view's decolorized contents.
|
||||
//
|
||||
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
|
||||
// resolve. A commit's diff spans several files, so we isolate the file section
|
||||
// containing targetIdx and parse just that one (see parseFileSection). Use this
|
||||
// for a single line, e.g. the one under the cursor; to resolve every line of a
|
||||
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
|
||||
//
|
||||
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
|
||||
// because the diff renderer restructured it, so that the caller can fall back.
|
||||
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
|
||||
if targetIdx < 0 || targetIdx >= len(bufferLines) {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
start, end := fileSectionBounds(bufferLines, targetIdx)
|
||||
if start == -1 {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start]
|
||||
return r.parsed, r.ok
|
||||
}
|
||||
|
||||
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file)
|
||||
// diff buffer in one pass, parsing each file section exactly once. It is the
|
||||
// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer:
|
||||
// resolving line by line would re-parse a section once per line of it — O(n²) on
|
||||
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
|
||||
// with bufferLines; a line in an unparseable section, or above the first one, is
|
||||
// left ok=false.
|
||||
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(bufferLines))
|
||||
for i := 0; i < len(bufferLines); {
|
||||
if !startsFileSection(bufferLines[i]) {
|
||||
i++ // in no file section; leave it unresolved
|
||||
continue
|
||||
}
|
||||
end := fileSectionEnd(bufferLines, i)
|
||||
copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines)))
|
||||
i = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fileSectionBounds returns the half-open range [start, end) of the file section
|
||||
// containing targetIdx: the nearest line starting a section at or above it, up to
|
||||
// where that section ends. start is -1 when targetIdx is above the first file
|
||||
// section, or below the end of the last one that begins above it.
|
||||
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
|
||||
for start = targetIdx; start >= 0; start-- {
|
||||
if !startsFileSection(bufferLines[start]) {
|
||||
continue
|
||||
}
|
||||
if end = fileSectionEnd(bufferLines, start); targetIdx < end {
|
||||
return start, end
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// fileSectionEnd returns the line the file section beginning at start ends before.
|
||||
//
|
||||
// A file's section runs to the next one, since every line between them is part of
|
||||
// its diff. A submodule's runs only as far as what git writes for it — the line
|
||||
// naming it, and the log of the commits it moved over — because the lines after
|
||||
// that need not belong to any section at all. A diff renderer's output has no
|
||||
// "diff --git" line to stop at, and the rows it puts between one file and the next
|
||||
// belong to neither.
|
||||
func fileSectionEnd(bufferLines []string, start int) int {
|
||||
if submodulePath(bufferLines[start]) != "" {
|
||||
end := start + 1
|
||||
for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
for i := start + 1; i < len(bufferLines); i++ {
|
||||
if startsFileSection(bufferLines[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(bufferLines)
|
||||
}
|
||||
|
||||
// startsFileSection reports whether the line opens a section of a diff: git's header
|
||||
// for a file of the repo, or the line a submodule's section begins with.
|
||||
func startsFileSection(line string) bool {
|
||||
return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != ""
|
||||
}
|
||||
|
||||
// isSubmoduleLogLine reports whether the line is one of the commits git lists under
|
||||
// a submodule's header, which it writes as two spaces, the direction the commit was
|
||||
// moved in, and the commit's subject.
|
||||
func isSubmoduleLogLine(line string) bool {
|
||||
return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ")
|
||||
}
|
||||
|
||||
// submodulePath returns the submodule whose section the given line opens, and "" for
|
||||
// every other line.
|
||||
//
|
||||
// A submodule gets no "diff --git" header and no hunks: git states which commits it
|
||||
// moved between and lists them, so that one line is all there is to take the path
|
||||
// from. The prefix is tested first so that the pattern is run over next to no lines
|
||||
// of a diff.
|
||||
func submodulePath(line string) string {
|
||||
if !strings.HasPrefix(line, submodulePrefix) {
|
||||
return ""
|
||||
}
|
||||
if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFileSection parses one file's diff section (fileLines, starting at the line
|
||||
// that opens it) a single time and returns the identity of each of its
|
||||
// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the
|
||||
// section's buffer lines, so the type and the old/new line numbers fall out of
|
||||
// the patch arithmetic. A submodule's section has no hunks at all, so every row of
|
||||
// it comes out as a header of the submodule, which is what they are: what git states
|
||||
// there is which commits it moved between, not lines of a file.
|
||||
//
|
||||
// Every line is left ok=false when the section has no
|
||||
// recoverable path or isn't a well-formed unified diff — the rendering
|
||||
// restructured it, and acting on a mis-parse would land us on the wrong line, so
|
||||
// the caller should fall back.
|
||||
//
|
||||
// endsTheBuffer says the section runs to the end of what we were given. That is
|
||||
// where a diff we have only part of breaks off. A long one is read a screenful
|
||||
// at a time and the rest as the user scrolls, so its last hunk holds fewer lines
|
||||
// than its header declares until the reading is done. Insisting on the whole
|
||||
// hunk there would leave every line of the file unresolved while the diff is the
|
||||
// one on screen, so a section in that position is held to what has arrived.
|
||||
func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(fileLines))
|
||||
|
||||
relPath := pathFromDiffHeader(fileLines)
|
||||
if relPath == "" {
|
||||
return result
|
||||
}
|
||||
p := patch.Parse(strings.Join(fileLines, "\n"))
|
||||
isWellFormed := p.IsWellFormed
|
||||
if endsTheBuffer {
|
||||
isWellFormed = p.IsWellFormedSoFar
|
||||
}
|
||||
if !isWellFormed() {
|
||||
return result
|
||||
}
|
||||
patchLines := p.Lines()
|
||||
for i := range fileLines {
|
||||
if i >= len(patchLines) {
|
||||
break
|
||||
}
|
||||
parsed := parsedDiffLine{
|
||||
RelPath: relPath,
|
||||
Type: diffLineTypeForKind(patchLines[i].Kind),
|
||||
NewLine: p.LineNumberOfLine(i),
|
||||
}
|
||||
if parsed.Type == types.DiffLineDeleted {
|
||||
parsed.OldLine = p.OldLineNumberOfLine(i)
|
||||
}
|
||||
result[i] = bufferLineParse{parsed, true}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
|
||||
switch kind {
|
||||
case patch.PATCH_HEADER:
|
||||
return types.DiffLineFileHeader
|
||||
case patch.HUNK_HEADER:
|
||||
return types.DiffLineHunkHeader
|
||||
case patch.ADDITION:
|
||||
return types.DiffLineAdded
|
||||
case patch.DELETION:
|
||||
return types.DiffLineDeleted
|
||||
case patch.CONTEXT:
|
||||
return types.DiffLineContext
|
||||
default:
|
||||
return types.DiffLineOther
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffHeader extracts the new-file path of a single diff section. A
|
||||
// submodule's section states its path in the line it opens with. For a file of the
|
||||
// repo the path comes from the "+++ b/<path>" line, falling back to "--- a/<path>"
|
||||
// when the new path is /dev/null (a deleted file), and to the "diff --git" line when
|
||||
// there are no such lines at all (a pure rename, which has no hunks).
|
||||
func pathFromDiffHeader(fileLines []string) string {
|
||||
if path := submodulePath(fileLines[0]); path != "" {
|
||||
return path
|
||||
}
|
||||
|
||||
var oldPath, newPath string
|
||||
for _, line := range fileLines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
break // past the header
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
newPath = stripDiffPathPrefix(strings.TrimPrefix(line, "+++ "))
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
oldPath = stripDiffPathPrefix(strings.TrimPrefix(line, "--- "))
|
||||
}
|
||||
}
|
||||
|
||||
if newPath != "" && newPath != "/dev/null" {
|
||||
return newPath
|
||||
}
|
||||
if oldPath != "" && oldPath != "/dev/null" {
|
||||
return oldPath
|
||||
}
|
||||
return pathFromDiffGitLine(fileLines[0])
|
||||
}
|
||||
|
||||
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
|
||||
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
|
||||
// so they are always there.
|
||||
func stripDiffPathPrefix(path string) string {
|
||||
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
|
||||
return path[2:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
|
||||
// line. A path containing " b/" would defeat this, but the +++/--- lines are
|
||||
// unambiguous and we only get here when they are absent.
|
||||
func pathFromDiffGitLine(line string) string {
|
||||
rest := strings.TrimPrefix(line, diffFilePrefix)
|
||||
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
|
||||
return rest[idx+len(" b/"):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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{RelPath: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
|
||||
{"hunk header", 4, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
|
||||
{"context line", 5, parsedDiffLine{RelPath: "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{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", 7, parsedDiffLine{RelPath: "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{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
|
||||
{"second addition", 16, parsedDiffLine{RelPath: "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{RelPath: "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{RelPath: "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{RelPath: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferSubmodule(t *testing.T) {
|
||||
// A submodule has no "diff --git" header and no hunks: git opens its section with
|
||||
// the commits it moved between and lists them below. So the section ends the one
|
||||
// above it, and every row of it belongs to the submodule as a whole.
|
||||
withSubmodule := strings.Split(`diff --git a/file.txt b/file.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
@@ -1,2 +1,3 @@
|
||||
hello
|
||||
world
|
||||
+world
|
||||
Submodule modules/xyz a32f27c..2d9f921:
|
||||
> bump the thing`, "\n")
|
||||
|
||||
for _, targetIdx := range []int{8, 9} {
|
||||
result, ok := parseDiffLineFromBuffer(withSubmodule, targetIdx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t,
|
||||
parsedDiffLine{RelPath: "modules/xyz", Type: types.DiffLineFileHeader, NewLine: 1},
|
||||
result, "line %d", targetIdx)
|
||||
}
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(withSubmodule, 7)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{RelPath: "file.txt", Type: types.DiffLineAdded, NewLine: 3}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferSubmoduleInARendering(t *testing.T) {
|
||||
// A diff renderer passes the lines git writes for a submodule through as they
|
||||
// are, while printing nothing below them that a section could end at. The
|
||||
// section has to end where what git writes for the submodule ends all the same:
|
||||
// the rows below belong to the other files of the diff, and can only be placed
|
||||
// by the records the renderer states for them.
|
||||
rendered := strings.Split(`Submodule modules/xyz a32f27c..2d9f921:
|
||||
> bump the thing
|
||||
|
||||
products/a.txt
|
||||
|
||||
one
|
||||
two`, "\n")
|
||||
|
||||
all := parseAllDiffLinesFromBuffer(rendered)
|
||||
assert.Equal(t, "modules/xyz", all[0].parsed.RelPath)
|
||||
assert.Equal(t, "modules/xyz", all[1].parsed.RelPath)
|
||||
for i := 2; i < len(rendered); i++ {
|
||||
assert.False(t, all[i].ok, "line %d: %q", i, rendered[i])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmodulePath(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{"moved on", "Submodule modules/xyz a32f27c..2d9f921:", "modules/xyz"},
|
||||
{"moved back", "Submodule modules/xyz 2d9f921..a32f27c (rewind):", "modules/xyz"},
|
||||
{"moved sideways", "Submodule modules/xyz a32f27c...2d9f921:", "modules/xyz"},
|
||||
{"added", "Submodule modules/xyz 0000000...2d9f921 (new submodule)", "modules/xyz"},
|
||||
{"removed", "Submodule modules/xyz a32f27c...0000000 (submodule deleted)", "modules/xyz"},
|
||||
{"commits missing", "Submodule modules/xyz a32f27c...2d9f921 (commits not present)", "modules/xyz"},
|
||||
{"dirty", "Submodule modules/xyz contains modified content", "modules/xyz"},
|
||||
{"with something new in it", "Submodule modules/xyz contains untracked content", "modules/xyz"},
|
||||
// git writes the path unquoted, so one with a space in it, or one ending in
|
||||
// something that reads like a range of commits, is told apart by matching the
|
||||
// last range on the line.
|
||||
{"path with a space", "Submodule my modules/xyz a32f27c..2d9f921:", "my modules/xyz"},
|
||||
{"path reading like a range", "Submodule a32f27c..2d9f921 deadbee..fa1afe1:", "a32f27c..2d9f921"},
|
||||
// A line of a file that reads like one of these is indented by the column the
|
||||
// diff states the line's side in, so it cannot be mistaken for one.
|
||||
{"a line of a file", " Submodule modules/xyz contains modified content", ""},
|
||||
{"something else entirely", "Submodule support was added", ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, submodulePath(s.line))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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{RelPath: "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 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)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type Helpers struct {
|
||||
Snake *SnakeHelper
|
||||
// lives in context package because our contexts need it to render to main
|
||||
Diff *DiffHelper
|
||||
DiffLine *DiffLineHelper
|
||||
Repos *ReposHelper
|
||||
RecordDirectory *RecordDirectoryHelper
|
||||
Update *UpdateHelper
|
||||
@@ -76,6 +77,7 @@ func NewStubHelpers() *Helpers {
|
||||
Commits: &CommitsHelper{},
|
||||
Snake: &SnakeHelper{},
|
||||
Diff: &DiffHelper{},
|
||||
DiffLine: &DiffLineHelper{},
|
||||
Repos: &ReposHelper{},
|
||||
RecordDirectory: &RecordDirectoryHelper{},
|
||||
Update: &UpdateHelper{},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package types
|
||||
|
||||
// DiffLineType classifies a row of a rendered diff.
|
||||
type DiffLineType int
|
||||
|
||||
const (
|
||||
DiffLineFileHeader DiffLineType = iota
|
||||
DiffLineHunkHeader
|
||||
DiffLineContext
|
||||
DiffLineAdded
|
||||
DiffLineDeleted
|
||||
// DiffLineOther is anything that isn't one of the above, e.g. the
|
||||
// "\ No newline at end of file" marker.
|
||||
DiffLineOther
|
||||
)
|
||||
|
||||
// DiffLineInfo is the identity of a row of a rendered diff in terms of the patch
|
||||
// it was rendered from: which file the row belongs to, what kind of row it is,
|
||||
// and where the line sits in the old and new versions of that file. This lets us
|
||||
// act on the line the user is pointing at in a diff view: stage it, open it in an
|
||||
// editor, keep the cursor on it across a re-render. The rendered text alone tells
|
||||
// us none of that.
|
||||
type DiffLineInfo struct {
|
||||
// Path is the absolute path of the file the line belongs to.
|
||||
Path string
|
||||
Type DiffLineType
|
||||
// NewLine is the line's position in the new version of the file. Set for all
|
||||
// content lines (for a deletion it is the position the deletion sits at) and
|
||||
// for hunk headers (the first line of the hunk they head).
|
||||
NewLine int
|
||||
// OldLine is the line's position in the old version of the file. Set only
|
||||
// for deletions, which are the only rows that need it: two consecutive
|
||||
// deletions share a new-file position and differ only here.
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// IsChange reports whether the row is an added or deleted line, as opposed to a
|
||||
// context line or a header. It mirrors patch.PatchLine.IsChange: those are the
|
||||
// rows a patch is built from, and the rows navigation moves between.
|
||||
func (self DiffLineInfo) IsChange() bool {
|
||||
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
|
||||
}
|
||||
Reference in New Issue
Block a user