import { describe, it, expect } from 'vitest'; import { parseDiffHunks, parseDiffHunksResult } from '../../src/storage/git.js'; describe('parseDiffHunks', () => { it('parses a single file with one hunk', () => { const diff = [ 'diff --git a/src/foo.ts b/src/foo.ts', '--- a/src/foo.ts', '+++ b/src/foo.ts', '@@ -10,0 +11,3 @@ some context', '+line1', '+line2', '+line3', ].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(1); expect(result[0].filePath).toBe('src/foo.ts'); expect(result[0].hunks).toEqual([{ startLine: 11, endLine: 13 }]); }); it('parses multiple hunks in a single file', () => { const diff = [ 'diff --git a/src/bar.ts b/src/bar.ts', '--- a/src/bar.ts', '+++ b/src/bar.ts', '@@ -5,2 +5,4 @@ context', ' unchanged', '+added', '@@ -20,0 +22,1 @@ more context', '+another line', ].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(1); expect(result[0].hunks).toHaveLength(2); expect(result[0].hunks[0]).toEqual({ startLine: 5, endLine: 8 }); expect(result[0].hunks[1]).toEqual({ startLine: 22, endLine: 22 }); }); it('parses multiple files', () => { const diff = [ 'diff --git a/a.ts b/a.ts', '--- a/a.ts', '+++ b/a.ts', '@@ -1,0 +1,2 @@', '+line', 'diff --git a/b.ts b/b.ts', '--- a/b.ts', '+++ b/b.ts', '@@ -10,3 +10,5 @@', ' ctx', ].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(2); expect(result[0].filePath).toBe('a.ts'); expect(result[0].hunks).toEqual([{ startLine: 1, endLine: 2 }]); expect(result[1].filePath).toBe('b.ts'); expect(result[1].hunks).toEqual([{ startLine: 10, endLine: 14 }]); }); it('handles single-line hunks without count', () => { // When count is omitted from @@ header, it defaults to 1 const diff = ['+++ b/src/single.ts', '@@ -5,0 +6 @@ context', '+one line'].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(1); expect(result[0].hunks).toEqual([{ startLine: 6, endLine: 6 }]); }); it('anchors a pure-deletion hunk (count=0) on the line the removed text followed', () => { // A unified diff spells an empty new range as the line BEFORE it: `+10,0` // means the removed text sat between new lines 10 and 11. Line 10 alone, // never the pair straddling the gap — a symbol that CONTAINED the deleted // text also contains 10, whereas extending to 11 would additionally claim a // symbol that merely STARTS after the gap, the widening `coalesceHunks` // guarantees never happens. // // Dropping the hunk left the file entry with no hunks, so `detect_changes` // contributed no bound for the path and a deletion-only commit reported // `{changed_count: 0, changed_files: 1, risk_level: 'low'}` — rendered as // "No changes detected." for a commit that deleted a function (#2915). const diff = ['+++ b/src/del.ts', '@@ -10,3 +10,0 @@ context'].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(1); expect(result[0].hunks).toEqual([{ startLine: 10, endLine: 10 }]); }); it('clamps a head-of-file deletion (+0,0) to line 1', () => { // git writes `+0,0` when the deletion takes the very first lines: there is // no "line before" to anchor on. Line numbers here are 1-based (#2377), so // an unclamped 0 would convert to the graph line -1 and match nothing. const diff = ['+++ b/src/head.ts', '@@ -1,2 +0,0 @@'].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(1); expect(result[0].hunks).toEqual([{ startLine: 1, endLine: 1 }]); }); it('maps a whole-file deletion to the old-side range', () => { const diff = [ 'diff --git a/src/deleted.ts b/src/deleted.ts', 'deleted file mode 100644', '--- a/src/deleted.ts', '+++ /dev/null', '@@ -1,9 +0,0 @@', '-export function removed() {}', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([ { filePath: 'src/deleted.ts', hunks: [{ startLine: 1, endLine: 9 }] }, ]); }); it('returns empty array for empty diff output', () => { expect(parseDiffHunks('')).toEqual([]); }); it('returns empty array for diff with no file headers', () => { expect(parseDiffHunks('nothing useful here\n')).toEqual([]); }); it('assigns hunks to the correct file when files are interleaved', () => { // Realistic multi-file diff with context lines between const diff = [ 'diff --git a/src/alpha.ts b/src/alpha.ts', 'index abc..def 100644', '--- a/src/alpha.ts', '+++ b/src/alpha.ts', '@@ -100,0 +101,2 @@ export function alpha() {', '+ const x = 1;', '+ return x;', 'diff --git a/src/beta.ts b/src/beta.ts', 'index 111..222 100644', '--- a/src/beta.ts', '+++ b/src/beta.ts', '@@ -50,0 +51,1 @@ export class Beta {', '+ private val = 0;', '@@ -80,0 +82,3 @@ export class Beta {', '+ doStuff() {', '+ return this.val;', '+ }', ].join('\n'); const result = parseDiffHunks(diff); expect(result).toHaveLength(2); expect(result[0].filePath).toBe('src/alpha.ts'); expect(result[0].hunks).toEqual([{ startLine: 101, endLine: 102 }]); expect(result[1].filePath).toBe('src/beta.ts'); expect(result[1].hunks).toHaveLength(2); expect(result[1].hunks[0]).toEqual({ startLine: 51, endLine: 51 }); expect(result[1].hunks[1]).toEqual({ startLine: 82, endLine: 84 }); }); it('retains binary-only files from the git header', () => { const diff = [ 'diff --git a/assets/logo.png b/assets/logo.png', 'index 1111111..2222222 100644', 'Binary files a/assets/logo.png and b/assets/logo.png differ', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([{ filePath: 'assets/logo.png', hunks: [] }]); }); it('retains the destination of a rename-only diff', () => { const diff = [ 'diff --git a/src/old.ts b/src/new.ts', 'similarity index 100%', 'rename from src/old.ts', 'rename to src/new.ts', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([{ filePath: 'src/new.ts', hunks: [] }]); }); it('keeps line ranges for whitespace-only hunks', () => { const diff = [ 'diff --git a/src/format.ts b/src/format.ts', '--- a/src/format.ts', '+++ b/src/format.ts', '@@ -4,2 +4,2 @@ function format() {', '- return value;', '+return value;', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([ { filePath: 'src/format.ts', hunks: [{ startLine: 4, endLine: 5 }] }, ]); }); it('retains a mode-only change from the git header', () => { const diff = ['diff --git a/script.sh b/script.sh', 'old mode 100644', 'new mode 100755'].join( '\n', ); expect(parseDiffHunks(diff)).toEqual([{ filePath: 'script.sh', hunks: [] }]); }); it('decodes a C-quoted metadata-only header', () => { const diff = [ 'diff --git "a/assets/\\344\\270\\255.png" "b/assets/\\344\\270\\255.png"', 'Binary files differ', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([{ filePath: 'assets/中.png', hunks: [] }]); }); it('does not attach a later C-quoted hunk to a previous metadata-only file', () => { const diff = [ 'diff --git a/script.sh b/script.sh', 'old mode 100644', 'new mode 100755', 'diff --git "a/src/\\344\\275\\240\\345\\245\\275.ts" "b/src/\\344\\275\\240\\345\\245\\275.ts"', '--- "a/src/\\344\\275\\240\\345\\245\\275.ts"', '+++ "b/src/\\344\\275\\240\\345\\245\\275.ts"', '@@ -1,0 +1,1 @@', '+export const ok = 1;', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([ { filePath: 'script.sh', hunks: [] }, { filePath: 'src/你好.ts', hunks: [{ startLine: 1, endLine: 1 }] }, ]); }); it('strips the unified-diff TAB on +++ so a spaced path is one FileDiff', () => { const diff = [ 'diff --git a/My Documents/file.ts b/My Documents/file.ts', '--- a/My Documents/file.ts', '+++ b/My Documents/file.ts\t', '@@ -1,0 +1,1 @@', '+x', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([ { filePath: 'My Documents/file.ts', hunks: [{ startLine: 1, endLine: 1 }] }, ]); }); it('recovers a same-path dest that itself contains " b/"', () => { const diff = [ 'diff --git a/foo b/bar.png b/foo b/bar.png', 'Binary files a/foo b/bar.png and b/foo b/bar.png differ', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([{ filePath: 'foo b/bar.png', hunks: [] }]); }); it('prefers rename to over a greedy b/ split', () => { const diff = [ 'diff --git a/plain.ts b/foo b/plain.ts', 'similarity index 100%', 'rename from plain.ts', 'rename to foo b/plain.ts', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([{ filePath: 'foo b/plain.ts', hunks: [] }]); }); it('keeps one FileDiff when a content line repeats +++ b/', () => { const diff = [ 'diff --git a/code.py b/code.py', '--- a/code.py', '+++ b/code.py', '@@ -5,0 +5,1 @@', '+++ b/code.py', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([ { filePath: 'code.py', hunks: [{ startLine: 5, endLine: 5 }] }, ]); }); it('does not treat a quoted +++ content line as a second file', () => { const diff = [ 'diff --git a/src/foo.ts b/src/foo.ts', '--- a/src/foo.ts', '+++ b/src/foo.ts', '@@ -1,0 +1,1 @@', '+++ "b/generated.ts"', ].join('\n'); expect(parseDiffHunks(diff)).toEqual([ { filePath: 'src/foo.ts', hunks: [{ startLine: 1, endLine: 1 }] }, ]); }); it('parses a quoted source and unquoted dest on the same git header', () => { const diff = [ 'diff --git "a/old name.ts" b/new.ts', 'similarity index 100%', 'rename from old name.ts', 'rename to new.ts', ].join('\n'); expect(parseDiffHunksResult(diff)).toEqual({ files: [{ filePath: 'new.ts', hunks: [] }], unparsedGitHeaders: 0, }); }); it('parses an unquoted source and quoted dest on the same git header', () => { const diff = [ 'diff --git a/old.ts "b/new name.ts"', 'similarity index 100%', 'rename from old.ts', 'rename to new name.ts', ].join('\n'); expect(parseDiffHunksResult(diff)).toEqual({ files: [{ filePath: 'new name.ts', hunks: [] }], unparsedGitHeaders: 0, }); }); it('counts an unparsed git header and does not keep current live', () => { const diff = [ 'diff --git a/script.sh b/script.sh', 'old mode 100644', 'new mode 100755', 'diff --git not-a-valid-header', '@@ -1,0 +1,1 @@', '+stolen', ].join('\n'); expect(parseDiffHunksResult(diff)).toEqual({ files: [{ filePath: 'script.sh', hunks: [] }], unparsedGitHeaders: 1, }); }); });