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 }, ], }])