jesseduffield.lazygit/pkg/commands/patch/hunk.go
Stefan Haller cf8e5fd27e Recover diff-line identity by parsing the buffer, behind a swappable seam
The focused main view's click/enter/e/G handlers all need the same thing: given
a rendered diff row, the patch-space line it corresponds to. Until now that came
solely from delta's lazygit-edit:// hyperlinks, which only carry a path and a
single line number — no side. That's lossy: for a deletion the number is the old
line, but the consumers fed it into new-file lookups, and two consecutive
deletions (which share a new-file line number) couldn't be told apart at all.

Replace GetFileAndLineForClickedDiffLine with GetDiffLineInfo, returning the
fuller (file, type, new-line, old-line) record from diff-line-metadata-notes.md.
This is mechanism #1: parse the decolorized view buffer — walk up to the file's
"diff --git" section, reuse patch.Parse on it (splitting multi-file commit diffs
on the "diff --git" boundaries), and read the type and line numbers off the patch
arithmetic. It serves the structure-preserving renderings — no pager, git diff
--color, and delta --color-only without line numbers — with no external
dependency.

To avoid trusting a mis-parse, the parser bails when a hunk's body no longer
matches its header (Patch.IsWellFormed). That's what happens when a pager keeps
the diff/hunk headers but restructures the body: delta's line-number gutters push
the +/- marker off the start of each line, so every body line reads as context.
Such renderings fall through to the next backend rather than yielding a confident
wrong answer. (diff-so-fancy goes further and rewrites the headers too, so it
fails even earlier, on the missing "diff --git".)

GetDiffLineInfo is a seam with swappable backends: the buffer parser first, then
the old hyperlink reader as a fallback for renderings the parser can't handle
(delta's default mode, or delta with line-number gutters). The future #2 OSC
per-cell metadata reader plugs in ahead of both, behind the same record shape.

Wire the consumers to the record per that doc's field mapping:
- dive into staging/patch building lands on the exact patch line, looking a
  deletion up by its old-file line number (PatchLineForOldLineNumber) so the
  two-deletions case resolves correctly;
- `e` edits at the new-file line;
- `G` anchors the PR link on the left (old) side for a deletion, the right (new)
  side otherwise.

The hyperlink fallback can't convey the side, so it reports DiffLineOther, which
the consumers treat as a non-deletion — i.e. exactly today's behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00

73 lines
2.5 KiB
Go

package patch
import "fmt"
// Example hunk:
// @@ -16,2 +14,3 @@ func (f *CommitFile) Description() string {
// return f.Name
// -}
// +
// +// test
type Hunk struct {
// the line number of the first line in the old file ('16' in the above example)
oldStart int
// the line number of the first line in the new file ('14' in the above example)
newStart int
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
headerContext string
// the lengths declared in the hunk header ('2' and '3' in the above example),
// kept so we can verify the parsed body actually matches the header (see
// Patch.IsWellFormed). Only populated by Parse.
declaredOldLength int
declaredNewLength int
// the body of the hunk, excluding the header line
bodyLines []*PatchLine
}
// Returns the number of lines in the hunk in the original file ('2' in the above example)
func (self *Hunk) oldLength() int {
return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, DELETION})
}
// Returns the number of lines in the hunk in the new file ('3' in the above example)
func (self *Hunk) newLength() int {
return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, ADDITION})
}
// Returns true if the hunk contains any changes (i.e. if it's not just a context hunk).
// We'll end up with a context hunk if we're transforming a patch and one of the hunks
// has no selected lines.
func (self *Hunk) containsChanges() bool {
return nLinesWithKind(self.bodyLines, []PatchLineKind{ADDITION, DELETION}) > 0
}
// Returns the number of lines in the hunk, including the header line
func (self *Hunk) lineCount() int {
return len(self.bodyLines) + 1
}
// Returns all lines in the hunk, including the header line
func (self *Hunk) allLines() []*PatchLine {
lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
lines = append(lines, self.bodyLines...)
return lines
}
// Returns the header line, including the unified diff header and the context
func (self *Hunk) formatHeaderLine() string {
return fmt.Sprintf("%s%s", self.formatHeaderStart(), self.headerContext)
}
// Returns the first part of the header line i.e. the unified diff part (excluding any context)
func (self *Hunk) formatHeaderStart() string {
newLengthDisplay := ""
newLength := self.newLength()
// if the new length is 1, it's omitted
if newLength != 1 {
newLengthDisplay = fmt.Sprintf(",%d", newLength)
}
return fmt.Sprintf("@@ -%d,%d +%d%s @@", self.oldStart, self.oldLength(), self.newStart, newLengthDisplay)
}