GitNexus/gitnexus/test/unit/parse-diff-hunks.test.ts
Parafee41 68eca0ced8
fix: map deleted files to indexed symbol ranges (#3269)
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-12 06:14:13 +00:00

313 lines
11 KiB
TypeScript

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/<same-path>', () => {
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,
});
});
});