From b3c003b9ac2fd27e11a584707ff9343c086f09d6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 9 Aug 2026 12:57:50 +0200 Subject: [PATCH] Add an old-file counterpart to Patch.LineNumberOfLine Identifying a change line of a diff by its file line number needs both sides: two consecutive deletions sit at the same new-file position, so only their old-file line numbers tell them apart. LineNumberOfLine only answers for the new file, which leaves deletions ambiguous. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/patch/patch.go | 31 ++++++++++++++++++ pkg/commands/patch/patch_test.go | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/pkg/commands/patch/patch.go b/pkg/commands/patch/patch.go index fbbf3c935..8cff4d1fa 100644 --- a/pkg/commands/patch/patch.go +++ b/pkg/commands/patch/patch.go @@ -114,6 +114,37 @@ func (self *Patch) LineNumberOfLine(idx int) int { return hunk.newStart + offset } +// Takes a line index in the patch and returns the line number in the old file. +// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives +// the line's position in the old file (additions get the position they sit at). +// If the line is a header line, returns 1. +// If the line is a hunk header line, returns the first old-file line number in that hunk. +// If the line is out of range below, returns the last old-file line number in the last hunk. +func (self *Patch) OldLineNumberOfLine(idx int) int { + if idx < len(self.header) || len(self.hunks) == 0 { + return 1 + } + + hunkIdx := self.HunkContainingLine(idx) + // cursor out of range, return last file line number + if hunkIdx == -1 { + lastHunk := self.hunks[len(self.hunks)-1] + return lastHunk.oldStart + lastHunk.oldLength() - 1 + } + + hunk := self.hunks[hunkIdx] + hunkStartIdx := self.HunkStartIdx(hunkIdx) + idxInHunk := idx - hunkStartIdx + + if idxInHunk == 0 { + return hunk.oldStart + } + + lines := hunk.bodyLines[:idxInHunk-1] + offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT}) + return hunk.oldStart + offset +} + // Returns hunk index containing the line at the given patch line index func (self *Patch) HunkContainingLine(idx int) int { for hunkIdx, hunk := range self.hunks { diff --git a/pkg/commands/patch/patch_test.go b/pkg/commands/patch/patch_test.go index 4f84041d6..9b2474bd1 100644 --- a/pkg/commands/patch/patch_test.go +++ b/pkg/commands/patch/patch_test.go @@ -120,6 +120,20 @@ index 9320895..6d79956 100644 lemon ` +// Two deletions with no line between them: they share a new-file line number +// (both sit at the same new-file position), so only their old-file line numbers +// tell them apart. +const consecutiveDeletions = `diff --git a/filename b/filename +index 9320895..6d79956 100644 +--- a/filename ++++ b/filename +@@ -1,4 +1,2 @@ + apple +-grape +-pear + lemon +` + const newFile = `diff --git a/newfile b/newfile new file mode 100644 index 0000000..4e680cc @@ -682,6 +696,46 @@ func TestLineNumberOfLine(t *testing.T) { } } +func TestOldLineNumberOfLine(t *testing.T) { + type scenario struct { + testName string + patchStr string + indexes []int + expecteds []int + } + + scenarios := []scenario{ + { + testName: "twoChangesInOneHunk", + patchStr: twoChangesInOneHunk, + indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000}, + expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5}, + }, + { + testName: "consecutiveDeletions", + patchStr: consecutiveDeletions, + indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000}, + expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4}, + }, + { + testName: "renameWithModificationDiff", + patchStr: renameWithModificationDiff, + indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000}, + expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + for i, idx := range s.indexes { + patch := Parse(s.patchStr) + result := patch.OldLineNumberOfLine(idx) + assert.Equal(t, s.expecteds[i], result) + } + }) + } +} + func TestGetNextStageableLineIndex(t *testing.T) { type scenario struct { testName string