Compare commits

...
Author SHA1 Message Date
Robin Newhouse 08af8e8344 fix: prevent duplicate lines when replacing longer files with shorter content
## Problem

When using write_to_file or replace_in_file to replace a file with fewer lines
than the original, content from the original file would appear duplicated at
the end of the new file.

Example: Replacing a 20-line file with 5 lines would result in "original line 7"
appearing after the new content.

## Root Cause

In DiffViewProvider.ts update(), on final update (isFinal=true), the code only
replaced content up to currentLine+1. When the new content ended with a newline,
the VS Code Range semantics caused old content to "shift up" into the replacement
range, escaping the subsequent truncateDocument() call.

VS Code Range(startLine, 0, endLine, 0) replaces up to the START of endLine,
not through it. So if new content ends with \n, the old content shifts up
one line and survives truncation.

## Solution

On final update, replace the ENTIRE document using MAX_SAFE_INTEGER as the end
line instead of currentLine+1. This ensures all original content is replaced
regardless of line count differences or trailing newlines.

Supporting changes in VscodeDiffViewProvider.ts:
- replaceText() converts MAX_SAFE_INTEGER to actual document end position
- truncateDocument() uses valid (lastLine, lastLineLength) instead of
  out-of-bounds (lineCount, 0)

## Files Changed

- src/integrations/editor/DiffViewProvider.ts: Use MAX_SAFE_INTEGER on isFinal
- src/hosts/vscode/VscodeDiffViewProvider.ts: Handle MAX_SAFE_INTEGER, fix range
- src/test/diff-newline-repro.test.ts: Add 3 test cases including exact bug scenario
2026-01-06 21:45:00 -08:00
4 changed files with 89 additions and 17 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: prevent duplicate lines when replacing longer files with shorter content
+22 -16
View File
@@ -105,25 +105,27 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
const document = this.activeDiffEditor.document
const edit = new vscode.WorkspaceEdit()
// IMPORTANT: VS Code may treat an out-of-bounds end position as an insertion instead of a
// replacement. Always validate the range against the current document to keep edits
// strictly within the real end-of-file.
// Calculate the range to replace.
// When endLine is at or beyond document.lineCount, we need to replace to the actual
// end of the document (last character of last line). Otherwise, we use the original
// semantics of (startLine, 0) to (endLine, 0) which means "up to the START of endLine".
const startLine = Math.max(0, Math.min(rangeToReplace.startLine, document.lineCount - 1))
const desiredEndLine = Math.max(rangeToReplace.startLine, rangeToReplace.endLine)
const validatedRange = document.validateRange(
new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(desiredEndLine, 0)),
)
const endLine = rangeToReplace.endLine
edit.replace(document.uri, validatedRange, content)
await vscode.workspace.applyEdit(edit)
// Preserve trailing newline: if content ends with newline, ensure document does too
if (content.endsWith("\n") && !document.getText().endsWith("\n")) {
const fixEdit = new vscode.WorkspaceEdit()
fixEdit.insert(document.uri, document.lineAt(Math.max(0, document.lineCount - 1)).range.end, "\n")
await vscode.workspace.applyEdit(fixEdit)
let range: vscode.Range
if (endLine >= document.lineCount) {
// Replacing to end of document - use document's actual end position
const lastLine = Math.max(0, document.lineCount - 1)
const lastLineLength = document.lineAt(lastLine).text.length
range = new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(lastLine, lastLineLength))
} else {
// Replacing a specific range - use start of end line (original semantics)
range = new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(endLine, 0))
}
edit.replace(document.uri, range, content)
await vscode.workspace.applyEdit(edit)
if (currentLine !== undefined) {
// Update decorations for the entire changed section
this.activeLineController?.setActiveLine(currentLine)
@@ -161,7 +163,11 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
const document = this.activeDiffEditor.document
if (lineNumber < document.lineCount) {
const edit = new vscode.WorkspaceEdit()
edit.delete(document.uri, new vscode.Range(lineNumber, 0, document.lineCount, 0))
// Use the actual end of document (last line, last character) instead of
// (lineCount, 0) which is an out-of-bounds position
const lastLine = document.lineCount - 1
const lastLineLength = document.lineAt(lastLine).text.length
edit.delete(document.uri, new vscode.Range(lineNumber, 0, lastLine, lastLineLength))
await vscode.workspace.applyEdit(edit)
}
// Clear all decorations at the end (before applying final edit)
+7 -1
View File
@@ -188,7 +188,13 @@ export abstract class DiffViewProvider {
contentToReplace += "\n"
}
const rangeToReplace = { startLine: 0, endLine: currentLine + 1 }
// On final update, replace the ENTIRE document to avoid leftover content.
// When new content is shorter than original, replacing only up to currentLine+1
// would leave old content that "shifts up" after replacement.
// Use Infinity to trigger end-of-document handling in replaceText.
const rangeToReplace = isFinal
? { startLine: 0, endLine: Number.MAX_SAFE_INTEGER }
: { startLine: 0, endLine: currentLine + 1 }
await this.replaceText(contentToReplace, rangeToReplace, currentLine)
// Scroll to the actual change location if provided.
+55
View File
@@ -79,4 +79,59 @@ describe("DiffViewProvider Newline handling", () => {
assert.strictEqual(result, "new content\n")
assert.strictEqual(result?.endsWith("\n"), true)
})
it("does not duplicate content when new content is shorter than original", async () => {
// This test covers the bug where content from the end of the original file
// would be duplicated/displaced when the new content has fewer lines.
const provider = new TestDiffViewProvider()
const originalContent = "line1\nline2\nline3\nline4\nline5\n"
provider.setup(originalContent)
// Replace entire file with shorter content
await provider.update("new1\nnew2\nnew3\n", true)
const result = await provider.getDocumentText()
// Should only contain the new content, no old content leftover
assert.strictEqual(result, "new1\nnew2\nnew3\n")
assert.ok(!result?.includes("line4"), "Old content should not be present")
assert.ok(!result?.includes("line5"), "Old content should not be present")
})
it("does not leave old content when replacing 20 lines with 5 lines (exact bug scenario)", async () => {
// This is the exact scenario reported in production:
// Original file has 20 lines, replaced with 5 lines
// Bug: "original line 7" was appearing at the end of the file
const provider = new TestDiffViewProvider()
let originalContent = ""
for (let i = 1; i <= 20; i++) {
originalContent += `original line ${i}\n`
}
provider.setup(originalContent)
// Replace entire file with 5 lines
await provider.update("new1\nnew2\nnew3\nnew4\nnew5\n", true)
const result = await provider.getDocumentText()
// Should only contain the 5 new lines
assert.strictEqual(result, "new1\nnew2\nnew3\nnew4\nnew5\n")
// Critical: NO original content should remain
assert.ok(!result?.includes("original"), "Original content should not be present")
})
it("handles streaming updates correctly without content duplication", async () => {
const provider = new TestDiffViewProvider()
const originalContent = "original1\noriginal2\noriginal3\noriginal4\noriginal5\n"
provider.setup(originalContent)
// Simulate streaming: multiple non-final updates followed by a final update
await provider.update("new1\n", false) // streaming
await provider.update("new1\nnew2\n", false) // streaming
await provider.update("new1\nnew2\nnew3\n", true) // final
const result = await provider.getDocumentText()
// Should only contain the final new content
assert.strictEqual(result, "new1\nnew2\nnew3\n")
assert.ok(!result?.includes("original"), "Original content should not be present")
})
})