Skip to content

x/tools/gopls: unified diff has a wrong new-file start line in hunks after the first #81453

Open

Description

@ajvengo

gopls version

golang.org/x/tools/gopls v0.23.0 (latest release), go1.27.1 darwin/arm64, macOS 26.6.2.

What did you do?

$ cat go.mod
module repro

go 1.21
$ cat -n p.go
     1	package p
     2	
     3	type T struct{}
     4	
     5	func (t T) Range() {}
     6	
     7	func a(t T) { t.Range() }
     8	// filler 1
     9	// filler 2
    10	// filler 3
    11	// filler 4
    12	// filler 5
    13	// filler 6
    14	// filler 7
    15	// filler 8
    16	func b(t T) { t.Range() }
$ gopls rename -d p.go:5:12 Each

What did you expect to see?

A rename changes no line counts, so every hunk header must have the same start line on both sides. Applying the same rename with gopls rename -w and diffing with GNU diff gives exactly that:

@@ -2,9 +2,9 @@
...
@@ -13,4 +13,4 @@

What did you see instead?

The second hunk's new-file start line is wrong. Everything else in the two diffs is byte-identical:

@@ -2,9 +2,9 @@
 
 type T struct{}
 
-func (t T) Range() {}
+func (t T) Each() {}
 
-func a(t T) { t.Range() }
+func a(t T) { t.Each() }
 // filler 1
 // filler 2
 // filler 3
@@ -13,4 +12,4 @@      <-- GNU diff says @@ -13,4 +13,4 @@
 // filler 6
 // filler 7
 // filler 8
-func b(t T) { t.Range() }
+func b(t T) { t.Each() }

The error is not a constant off-by-one

It grows with the size of the preceding hunks. Widening the gap between the two edits inside the first hunk moves the second hunk's old start while its new start stays frozen:

filler lines between the first two edits headers printed by gopls
0 @@ -2,12 +2,12 @@ @@ -20,4 +17,4 @@
2 @@ -2,12 +2,12 @@ @@ -20,4 +17,4 @@
4 @@ -2,14 +2,14 @@ @@ -22,4 +17,4 @@
6 @@ -2,16 +2,16 @@ @@ -24,4 +17,4 @@

On a real repository the error is larger. In golang.org/x/tools at v0.50.0, renaming the method Range on (go/callgraph/vta/internal/trie).Map to Each prints, for trie.go:

@@ -68,17 +68,17 @@
@@ -102,7 +94,7 @@      <-- off by 8

It is not specific to rename

gopls format -d prints the same kind of wrong header, so this looks like the shared unified-diff formatter (internal/diff) rather than the rename code path. On a file with two unformatted regions 12 lines apart:

@@ -1,9 +1,10 @@
@@ -17,6 +16,6 @@      <-- the first hunk adds a line, so this should be @@ -17,6 +18,6 @@

Here the header is wrong in the other direction, which is why the rename case above is the clearer reproducer: with no line-count changes the expected value is unambiguous.

Why this matters beyond cosmetics

GNU patch tolerates a wrong new-file start line, so this stays invisible in manual use. It does not stay invisible for programmatic consumers. The MCP tool go_rename_symbol returns this diff as its entire result and writes nothing to disk, so an MCP client has to apply the diff itself; a client that trusts the + start line places the edit in the wrong location. I ran into it while measuring exactly that: three agent runs each ended up re-deriving the rename as textual replacements instead of applying the returned diff, and one of them corrupted a string literal in the process.

Related, and lower priority

The same diffs use absolute paths with no a/ b/ prefixes, so git apply rejects them outright (error: invalid path '/private/tmp/.../p.go') regardless of the hunk headers. That may well be intended for human-readable CLI output, but for go_rename_symbol, whose output exists to be applied by a program, a patch-compatible form (or, better, a structured edit list) would be more useful than prose plus a diff.

Activity

  1. added
    ToolsThis label describes issues relating to any tools in the x/tools repository.
    goplsIssues related to the Go language server, gopls.
    on Sep 10, 2026
  2. added this to the Unreleased milestone on Sep 10, 2026
  3. ajvengo commented on Sep 10, 2026

    @ajvengo
    Author

    Cause

    toUnified keeps toLine as the running new-file line counter, but the branch that joins a nearby edit into the current hunk adds the intervening lines to the hunk without advancing it (internal/diff/unified.go, current master):

    		case h != nil && start <= last+gap:
    			//within range of previous lines, add the joiners
    			addEqualLines(h, lines, last, start)   // <-- toLine not advanced
    		default:
    			...
    			toLine += start - last                 // <-- only this path advances it

    The default path advances toLine by start - last, and each inserted line advances it by one, so those two are accounted for. The joiners are not. Every joined edit therefore leaves toLine short by exactly the number of joiner lines, and the error is carried into the header of every subsequent hunk.

    That predicts the magnitudes in the report. In the small reproducer the two edits are joined across one blank line, so the second hunk is off by 1. In x/tools the edits in trie.go are at lines 71, 78 and 81, joined across 6 and 2 lines, so the next hunk is off by 8 — @@ -102,7 +94,7 @@.

    Minimal fix

    --- a/internal/diff/unified.go
    +++ b/internal/diff/unified.go
    @@ -134,7 +134,7 @@ func toUnified(fromName, toName string, content string, edits []Edit, contextLine
     			//direct extension
     		case h != nil && start <= last+gap:
     			//within range of previous lines, add the joiners
    -			addEqualLines(h, lines, last, start)
    +			toLine += addEqualLines(h, lines, last, start)
     		default:

    addEqualLines already returns the number of lines it appended, and it is already used that way for the leading edge of a hunk just below, so the fix needs nothing new.

    Verification

    With that one line changed:

    • the reproducer from the report prints @@ -13,4 +13,4 @@, matching GNU diff byte for byte;
    • the gopls format -d case prints @@ -17,6 +18,6 @@, again matching gofmt + GNU diff -u (note the released binary is wrong in the other direction here, +16);
    • the x/tools rename prints @@ -102,7 +102,7 @@ instead of +94;
    • go test ./internal/... is green, internal/diff, internal/diff/difftest and internal/diff/lcs included.

    All four checks were run against a gopls built from the patched tree, since gopls/go.mod replaces golang.org/x/tools with ...

    A test that would have caught it

    The existing tests compare against golden strings, which is why a wrong header survived. A property is cheap here: every hunk header must point at the real position of its own lines — the old lines at fromLine in the old file, the new lines at toLine in the new file. On 400 randomly generated line-edit pairs (delete, change, insert) that check fails 112 times before the fix and 0 times after.

    var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
    
    // checkHunkPositions asserts that every hunk header points at the real position
    // of its own lines: the old lines (context and deletions) at fromLine in the old
    // file, the new lines (context and insertions) at toLine in the new file.
    func checkHunkPositions(t *testing.T, old, new string) {
    	t.Helper()
    	u := diff.Unified("old", "new", old, new)
    	if u == "" {
    		return
    	}
    	oldLines, newLines := strings.Split(old, "\n"), strings.Split(new, "\n")
    	var fromLine, toLine int
    	var fromBlock, toBlock []string
    	at := func(lines []string, start int, block []string) bool {
    		return start-1+len(block) <= len(lines) &&
    			strings.Join(lines[start-1:start-1+len(block)], "\n") == strings.Join(block, "\n")
    	}
    	verify := func() {
    		if fromLine == 0 {
    			return
    		}
    		if !at(oldLines, fromLine, fromBlock) {
    			t.Errorf("hunk claims old line %d, the old file has something else there:\n%s", fromLine, u)
    		}
    		if !at(newLines, toLine, toBlock) {
    			t.Errorf("hunk claims new line %d, the new file has something else there:\n%s", toLine, u)
    		}
    	}
    	for _, l := range strings.Split(u, "\n") {
    		switch {
    		case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "+++ "):
    		case strings.HasPrefix(l, "@@"):
    			verify()
    			m := hunkHeader.FindStringSubmatch(l)
    			if m == nil {
    				t.Fatalf("unparsable hunk header %q", l)
    			}
    			fromLine, _ = strconv.Atoi(m[1])
    			toLine, _ = strconv.Atoi(m[2])
    			fromBlock, toBlock = nil, nil
    		case strings.HasPrefix(l, "-"):
    			fromBlock = append(fromBlock, l[1:])
    		case strings.HasPrefix(l, "+"):
    			toBlock = append(toBlock, l[1:])
    		case strings.HasPrefix(l, " "):
    			fromBlock = append(fromBlock, l[1:])
    			toBlock = append(toBlock, l[1:])
    		}
    	}
    	verify()
    }

    I am happy to send this as a CL (the one-line fix plus the golden-string test for the reproducer and the property test) if that is welcome.

  4. pjweinb commented on Sep 10, 2026

    @pjweinb

    Sure. Fix it.

  5. shuaixr commented on Sep 18, 2026

    @shuaixr

    I'd happy to take a look at this if no one's working on it

  6. gopherbot commented on Sep 18, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/834825 mentions this issue: cmd/vendor/golang.org/x/tools: fix wrong new-file start line in unified diff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

ToolsThis label describes issues relating to any tools in the x/tools repository.goplsIssues related to the Go language server, gopls.help wanted

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions