From da6e3f9edf7a31e47a5495ae15e6f2516587e2cd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 15 Sep 2026 16:31:24 +0800 Subject: [PATCH] fix(workspace-changes): count an appended line as one addition The file tools persist both sides of a hunk without a trailing newline, so re-diffing an append read the previous last line as rewritten and reported +2 -1 for +1 -0. Both sides are line-terminated before the diff, the rule the per-call diff card already applies. --- .../deliverables/workspace-changes/src/numstat.ts | 13 +++++++++++-- .../workspace-changes/tests/numstat.spec.ts | 7 +++++++ .../workspace-changes/tests/plugin.spec.ts | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/deliverables/workspace-changes/src/numstat.ts b/packages/deliverables/workspace-changes/src/numstat.ts index 1e57db1072..cbc297b0fe 100644 --- a/packages/deliverables/workspace-changes/src/numstat.ts +++ b/packages/deliverables/workspace-changes/src/numstat.ts @@ -39,10 +39,19 @@ export function parseNumstat(output: string): NumstatEntry[] { return entries } +/** + * A side's text with every line terminated, so the last line compares by + * content alone: the file tools persist hunk sides without a trailing newline, + * and the same rule reads empty text as no lines rather than one empty line. + */ +function terminated(text: string): string { + return text === '' || text.endsWith('\n') ? text : `${text}\n` +} + /** * Sum the added and deleted lines over recorded hunks. Context lines appear on * both sides of a hunk and cancel out; a hunk without prior text counts every - * line as added. + * line as added; a trailing newline never counts as a changed line. * @param diffs - the applied hunks a file tool persisted with its result. * @returns line totals for one file. */ @@ -50,7 +59,7 @@ export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; del let added = 0 let deleted = 0 for (const diff of diffs) { - const patch = structuredPatch('', '', diff.oldText ?? '', diff.newText) + const patch = structuredPatch('', '', terminated(diff.oldText ?? ''), terminated(diff.newText)) for (const hunk of patch.hunks) { for (const line of hunk.lines) { if (line.startsWith('+')) added += 1 diff --git a/packages/deliverables/workspace-changes/tests/numstat.spec.ts b/packages/deliverables/workspace-changes/tests/numstat.spec.ts index 17157e5743..dec6e0f739 100644 --- a/packages/deliverables/workspace-changes/tests/numstat.spec.ts +++ b/packages/deliverables/workspace-changes/tests/numstat.spec.ts @@ -30,6 +30,13 @@ describe('hunkLineCounts', () => { { path: 'a', oldText: null, newText: 'x\ny\n' }, ])).toEqual({ added: 4, deleted: 1 }) }) + + it('appends a line after an unterminated last line without counting that line as rewritten', () => { + // The file tools persist hunk sides without a trailing newline; an append reads +1 -0, not +2 -1. + expect(hunkLineCounts([{ path: 'a', oldText: 'last', newText: 'last\nadded' }])).toEqual({ added: 1, deleted: 0 }) + expect(hunkLineCounts([{ path: 'a', oldText: 'last\n', newText: 'last\nadded\n' }])).toEqual({ added: 1, deleted: 0 }) + expect(hunkLineCounts([{ path: 'a', oldText: 'gone', newText: '' }])).toEqual({ added: 0, deleted: 1 }) + }) }) describe('fileDiffsOf', () => { diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index b2c627b3b4..6e762b92b3 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -259,7 +259,7 @@ describe('workspace-changes without a repository', () => { expect(changes(ctx, session)).toEqual([{ turn: 1, cwd, total: 2, files: [ - { path: 'existing.txt', display: 'existing.txt', added: 4, deleted: 2 }, + { path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 }, { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 }, ], }])