chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
/**
* Git Diff Tests
*/
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { extractDiff, validateOnBranch } from '../../../src/codeScan/git/diff';
import { GitError } from '../../../src/types/codeScan';
import type { SimpleGit, StatusResult } from 'simple-git';
// Define a partial mock type for the SimpleGit methods we use
type MockSimpleGit = Pick<SimpleGit, 'status' | 'branch' | 'diff' | 'log'> & {
status: Mock;
branch: Mock;
diff: Mock;
log: Mock;
};
// Mock simple-git with a factory that returns a mock instance
const mockGit: MockSimpleGit = {
status: vi.fn(),
branch: vi.fn(),
diff: vi.fn(),
log: vi.fn(),
};
vi.mock('simple-git', () => ({
default: vi.fn(() => mockGit),
}));
describe('Git Diff', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('validateOnBranch', () => {
it('should return current branch name when on a branch', async () => {
mockGit.status.mockResolvedValue({
current: 'feature/test-branch',
detached: false,
} as StatusResult);
const branchName = await validateOnBranch(mockGit as unknown as SimpleGit);
expect(branchName).toBe('feature/test-branch');
});
it('should throw GitError when in detached HEAD state', async () => {
mockGit.status.mockResolvedValue({
current: null,
detached: true,
} as StatusResult);
await expect(validateOnBranch(mockGit as unknown as SimpleGit)).rejects.toThrow(GitError);
await expect(validateOnBranch(mockGit as unknown as SimpleGit)).rejects.toThrow(
'Not on a branch',
);
});
});
describe('extractDiff', () => {
it('should extract diff comparing against main branch', async () => {
const mockDiff = `diff --git a/file.ts b/file.ts
index 123..456 100644
--- a/file.ts
+++ b/file.ts
@@ -1,3 +1,4 @@
+const newLine = 'test';
const existingLine = 'existing';`;
// Mock status (for validateOnBranch)
mockGit.status.mockResolvedValue({
current: 'feature/test',
detached: false,
} as StatusResult);
// Mock main branch exists
mockGit.branch.mockResolvedValue({
all: ['main', 'feature/test'],
branches: {},
current: 'feature/test',
detached: false,
} as any);
mockGit.diff.mockResolvedValue(mockDiff);
const result = await extractDiff('/fake/repo');
expect(result).toEqual({
diff: mockDiff,
baseBranch: 'main',
});
expect(mockGit.diff).toHaveBeenCalledWith(['main...HEAD']);
});
it('should fall back to master branch if main does not exist', async () => {
const mockDiff = 'diff content';
// Mock status (for validateOnBranch)
mockGit.status.mockResolvedValue({
current: 'feature/test',
detached: false,
} as StatusResult);
// Mock main branch does not exist, master does
mockGit.branch.mockResolvedValue({
all: ['master', 'feature/test'],
branches: {},
current: 'feature/test',
detached: false,
} as any);
mockGit.diff.mockResolvedValue(mockDiff);
const result = await extractDiff('/fake/repo');
expect(result).toEqual({
diff: mockDiff,
baseBranch: 'master',
});
expect(mockGit.diff).toHaveBeenCalledWith(['master...HEAD']);
});
it('should throw GitError when neither main nor master exists', async () => {
// Mock status (for validateOnBranch)
mockGit.status.mockResolvedValue({
current: 'feature/test',
detached: false,
} as StatusResult);
// Mock neither branch exists - should throw error
mockGit.branch.mockResolvedValue({
all: ['feature/test'],
branches: {},
current: 'feature/test',
detached: false,
} as any);
await expect(extractDiff('/fake/repo')).rejects.toThrow(GitError);
});
it('should throw GitError if no changes detected', async () => {
// Mock status (for validateOnBranch)
mockGit.status.mockResolvedValue({
current: 'feature/test',
detached: false,
} as StatusResult);
mockGit.branch.mockResolvedValue({
all: ['main', 'feature/test'],
branches: {},
current: 'feature/test',
detached: false,
} as any);
// Mock empty diff
mockGit.diff.mockResolvedValue('');
await expect(extractDiff('/fake/repo')).rejects.toThrow(GitError);
await expect(extractDiff('/fake/repo')).rejects.toThrow('No changes detected');
});
it('should throw GitError if diff extraction fails', async () => {
// Mock status (for validateOnBranch)
mockGit.status.mockResolvedValue({
current: 'feature/test',
detached: false,
} as StatusResult);
mockGit.branch.mockResolvedValue({
all: ['main', 'feature/test'],
branches: {},
current: 'feature/test',
detached: false,
} as any);
mockGit.diff.mockRejectedValue(new Error('git diff failed'));
await expect(extractDiff('/fake/repo')).rejects.toThrow(GitError);
await expect(extractDiff('/fake/repo')).rejects.toThrow('Failed to extract diff');
});
});
});
+340
View File
@@ -0,0 +1,340 @@
import { describe, expect, it } from 'vitest';
import { annotateSingleFileDiffWithLineNumbers } from '../../../src/codeScan/git/diffAnnotator';
describe('annotateDiffWithLineNumbers', () => {
describe('basic functionality', () => {
it('should handle empty patch', () => {
expect(annotateSingleFileDiffWithLineNumbers('')).toBe('');
expect(annotateSingleFileDiffWithLineNumbers(' ')).toBe(' ');
});
it('should preserve file headers without annotation', () => {
const patch = `diff --git a/test.ts b/test.ts
index abc123..def456 100644
--- a/test.ts
+++ b/test.ts`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(patch);
});
it('should annotate simple hunk with additions', () => {
const patch = `@@ -1,3 +1,4 @@
line 1
line 2
+new line 3
line 4`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1,3 +1,4 @@
L1: line 1
L2: line 2
L3: +new line 3
L4: line 4`);
});
it('should annotate simple hunk with deletions', () => {
const patch = `@@ -1,4 +1,3 @@
line 1
-deleted line
line 2
line 3`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1,4 +1,3 @@
L1: line 1
-deleted line
L2: line 2
L3: line 3`);
});
it('should annotate hunk with mixed changes', () => {
const patch = `@@ -5,5 +5,6 @@
context before
-old line
+new line 1
+new line 2
context after`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -5,5 +5,6 @@
L5: context before
-old line
L6: +new line 1
L7: +new line 2
L8: context after`);
});
});
describe('multiple hunks', () => {
it('should handle multiple hunks with correct line numbering', () => {
const patch = `@@ -1,3 +1,3 @@
line 1
-old line 2
+new line 2
line 3
@@ -10,2 +10,3 @@
line 10
+inserted line
line 11`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1,3 +1,3 @@
L1: line 1
-old line 2
L2: +new line 2
L3: line 3
@@ -10,2 +10,3 @@
L10: line 10
L11: +inserted line
L12: line 11`);
});
});
describe('full patch with headers', () => {
it('should annotate complete patch including file headers', () => {
const patch = `diff --git a/src/test.ts b/src/test.ts
index abc123..def456 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -1,3 +1,4 @@
import { foo } from 'bar';
+import { baz } from 'qux';
function test() {`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`diff --git a/src/test.ts b/src/test.ts
index abc123..def456 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -1,3 +1,4 @@
L1: import { foo } from 'bar';
L2: +import { baz } from 'qux';
L3:
L4: function test() {`);
});
});
describe('real-world example from PR #5', () => {
it('should correctly annotate the mcp tool definition hunk', () => {
// This is a simplified excerpt from the actual PR #5
const patch = `@@ -18,20 +20,64 @@ const getRetentionOffers = tool({
customer_id: z.string(),
account_type: z.string(),
current_plan: z.string(),
- tenure_months: z.integer(),
+ tenure_months: z.number().int(),
recent_complaints: z.boolean(),
}),
execute: async (input: {
customer_id: string;
account_type: string;
current_plan: string;
- tenure_months: integer;
+ tenure_months: number;
recent_complaints: boolean;
}) => {
- // TODO: Unimplemented
+ // Mock implementation - returns sample retention offers
+ return {
+ offers: [
+ {
+ type: "discount",
+ description: "20% off for 12 months",
+ monthly_savings: 15,
+ },
+ {
+ type: "upgrade",
+ description: "Free upgrade to premium plan for 3 months",
+ value: 45,
+ },
+ ],
+ };
},
});
+const mcp = hostedMcpTool({
+ serverLabel: "dropbox",
+ connectorId: "connector_dropbox",
+ serverDescription: "Return Policy Knowledge",
+ allowedTools: [
+ "fetch",
+ "fetch_file",
+ "get_profile",
+ "list_recent_files",
+ "search",
+ "search_files",
+ ],
+ requireApproval: "never",
+});`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
// Verify key line numbers
expect(result).toContain('L51: +const mcp = hostedMcpTool({');
expect(result).toContain('L63: + requireApproval: "never",');
expect(result).toContain('L64: +});');
// Verify that context lines are also numbered
expect(result).toContain('L20: customer_id: z.string(),');
expect(result).toContain('L21: account_type: z.string(),');
// Verify that removed lines have no line numbers
const lines = result.split('\n');
const removedLine = lines.find((l) => l.includes('tenure_months: z.integer()'));
expect(removedLine).toBeDefined();
expect(removedLine).toMatch(/^-/);
});
it('should verify line numbering is absolute, not relative to hunk', () => {
// This test explicitly verifies the bug we're fixing
// The hunk starts at line 20, so lines should be numbered starting from 20
const patch = `@@ -18,20 +20,10 @@
customer_id: z.string(),
account_type: z.string(),
current_plan: z.string(),
}),
execute: async (input: {
customer_id: string;
+const mcp = hostedMcpTool({
+ serverLabel: "dropbox",`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
// Should start numbering at 20 (the hunk start), not at 1
expect(result).toContain('L20: customer_id: z.string(),');
expect(result).toContain('L21: account_type: z.string(),');
// The mcp tool should be at L26 (20 + 6 lines)
expect(result).toContain('L26: +const mcp = hostedMcpTool({');
});
});
describe('edge cases', () => {
it('should handle hunk at start of file (line 1)', () => {
const patch = `@@ -0,0 +1,3 @@
+new file line 1
+new file line 2
+new file line 3`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -0,0 +1,3 @@
L1: +new file line 1
L2: +new file line 2
L3: +new file line 3`);
});
it('should handle "No newline at end of file" marker', () => {
const patch = `@@ -1,2 +1,2 @@
line 1
-line 2
\\ No newline at end of file
+line 2 with newline`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
// The marker line should be preserved
expect(result).toContain('\\ No newline at end of file');
// Verify the marker line itself doesn't have a line number
const lines = result.split('\n');
const markerLine = lines.find((l) => l.includes('No newline at end of file'));
expect(markerLine).toBe('\\ No newline at end of file');
});
it('should handle empty lines (context)', () => {
const patch = `@@ -1,4 +1,4 @@
line 1
line 3
+line 4`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1,4 +1,4 @@
L1: line 1
L2:
L3: line 3
L4: +line 4`);
});
it('should handle consecutive additions', () => {
const patch = `@@ -1,1 +1,5 @@
existing line
+added line 1
+added line 2
+added line 3
+added line 4`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1,1 +1,5 @@
L1: existing line
L2: +added line 1
L3: +added line 2
L4: +added line 3
L5: +added line 4`);
});
it('should handle consecutive deletions', () => {
const patch = `@@ -1,5 +1,1 @@
kept line
-deleted 1
-deleted 2
-deleted 3
-deleted 4`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1,5 +1,1 @@
L1: kept line
-deleted 1
-deleted 2
-deleted 3
-deleted 4`);
});
});
describe('hunk header variations', () => {
it('should handle hunk header without line count', () => {
const patch = `@@ -1 +1,2 @@
line 1
+line 2`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toBe(`@@ -1 +1,2 @@
L1: line 1
L2: +line 2`);
});
it('should handle hunk header with function context', () => {
const patch = `@@ -10,3 +10,4 @@ function myFunction() {
const x = 1;
const y = 2;
+ const z = 3;
return x + y;`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
expect(result).toContain('L10: const x = 1;');
expect(result).toContain('L11: const y = 2;');
expect(result).toContain('L12: + const z = 3;');
expect(result).toContain('L13: return x + y;');
});
});
describe('trailing content', () => {
it('should handle trailing blank line without annotation', () => {
// Git diffs often have trailing blank lines
const patch = `@@ -1,2 +1,2 @@
line 1
+added line
`;
const result = annotateSingleFileDiffWithLineNumbers(patch);
// The trailing blank should not be annotated
expect(result).toBe(`@@ -1,2 +1,2 @@
L1: line 1
L2: +added line
`);
});
});
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Git Metadata Tests
*/
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { extractMetadata } from '../../../src/codeScan/git/metadata';
import { GitMetadataError } from '../../../src/types/codeScan';
import type { SimpleGit } from 'simple-git';
// Define a partial mock type for the SimpleGit methods we use
type MockSimpleGit = Pick<SimpleGit, 'log' | 'revparse'> & {
log: Mock;
revparse: Mock;
};
// Mock simple-git with a factory that returns a mock instance
const mockGit: MockSimpleGit = {
log: vi.fn(),
revparse: vi.fn(),
};
vi.mock('simple-git', () => ({
default: vi.fn(() => mockGit),
}));
describe('Git Metadata', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('extractMetadata', () => {
it('should extract metadata from git log', async () => {
const mockLog = {
all: [
{
hash: 'abc1234',
date: '2025-01-15T10:30:00Z',
message: 'Add LLM integration',
author_name: 'Test User',
author_email: 'test@example.com',
},
{
hash: 'def5678',
date: '2025-01-14T15:20:00Z',
message: 'Fix prompt handling',
author_name: 'Test User',
author_email: 'test@example.com',
},
],
latest: {
hash: 'abc1234',
date: '2025-01-15T10:30:00Z',
message: 'Add LLM integration',
author_name: 'Test User',
author_email: 'test@example.com',
},
total: 2,
};
mockGit.log.mockResolvedValue(mockLog as any);
(mockGit.revparse as any).mockImplementation(async (options: any) => {
const ref = Array.isArray(options) ? options[0] : options;
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
});
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
expect(metadata).toEqual({
branch: 'feature/test',
baseBranch: 'main',
baseRef: 'main',
baseSha: 'base-sha-123456',
compareRef: 'feature/test',
compareSha: 'compare-sha-789012',
commitMessages: ['abc1234: Add LLM integration', 'def5678: Fix prompt handling'],
author: 'Test User',
timestamp: '2025-01-15T10:30:00Z',
});
expect(mockGit.log).toHaveBeenCalledWith({
from: 'main',
to: 'feature/test',
});
});
it('should handle single commit', async () => {
const mockLog = {
all: [
{
hash: 'abc1234567',
date: '2025-01-15T10:30:00Z',
message: 'Initial commit',
author_name: 'Test User',
author_email: 'test@example.com',
},
],
latest: {
hash: 'abc1234567',
date: '2025-01-15T10:30:00Z',
message: 'Initial commit',
author_name: 'Test User',
author_email: 'test@example.com',
},
total: 1,
};
mockGit.log.mockResolvedValue(mockLog as any);
(mockGit.revparse as any).mockImplementation(async (options: any) => {
const ref = Array.isArray(options) ? options[0] : options;
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
});
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
expect(metadata.commitMessages).toEqual(['abc1234: Initial commit']);
expect(metadata.author).toBe('Test User');
});
it('should handle no commits with default values', async () => {
const mockLog = {
all: [],
latest: null,
total: 0,
};
mockGit.log.mockResolvedValue(mockLog as any);
(mockGit.revparse as any).mockImplementation(async (options: any) => {
const ref = Array.isArray(options) ? options[0] : options;
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
});
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
expect(metadata.commitMessages).toEqual([]);
expect(metadata.author).toBe('Unknown');
expect(metadata.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); // ISO date format
});
it('should throw GitMetadataError if log extraction fails', async () => {
mockGit.log.mockRejectedValue(new Error('git log failed'));
await expect(extractMetadata('/fake/repo', 'main', 'feature/test')).rejects.toThrow(
GitMetadataError,
);
await expect(extractMetadata('/fake/repo', 'main', 'feature/test')).rejects.toThrow(
'Failed to extract git metadata',
);
});
it('should handle commits with multiline messages', async () => {
const mockLog = {
all: [
{
hash: 'abc123def',
date: '2025-01-15T10:30:00Z',
message: 'Add feature\n\nDetailed description here',
author_name: 'Test User',
author_email: 'test@example.com',
},
],
latest: {
hash: 'abc123def',
date: '2025-01-15T10:30:00Z',
message: 'Add feature\n\nDetailed description here',
author_name: 'Test User',
author_email: 'test@example.com',
},
total: 1,
};
mockGit.log.mockResolvedValue(mockLog as any);
(mockGit.revparse as any).mockImplementation(async (options: any) => {
const ref = Array.isArray(options) ? options[0] : options;
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
});
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
expect(metadata.commitMessages).toEqual([
'abc123d: Add feature\n\nDetailed description here',
]);
});
it('should use latest commit for author and timestamp', async () => {
const mockLog = {
all: [
{
hash: 'abc1234',
date: '2025-01-15T10:30:00Z',
message: 'Latest commit',
author_name: 'Latest Author',
author_email: 'latest@example.com',
},
{
hash: 'def5678',
date: '2025-01-14T15:20:00Z',
message: 'Older commit',
author_name: 'Old Author',
author_email: 'old@example.com',
},
],
latest: {
hash: 'abc1234',
date: '2025-01-15T10:30:00Z',
message: 'Latest commit',
author_name: 'Latest Author',
author_email: 'latest@example.com',
},
total: 2,
};
mockGit.log.mockResolvedValue(mockLog as any);
(mockGit.revparse as any).mockImplementation(async (options: any) => {
const ref = Array.isArray(options) ? options[0] : options;
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
});
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
expect(metadata.author).toBe('Latest Author');
expect(metadata.timestamp).toBe('2025-01-15T10:30:00Z');
});
});
});
+240
View File
@@ -0,0 +1,240 @@
/**
* Tests for git diff --raw -z parser
*
* These tests verify correct handling of renamed and copied files
*/
import { describe, expect, it } from 'vitest';
import { parseRawDiff } from '../../../src/codeScan/git/rawDiffParser';
describe('parseRawDiff', () => {
describe('basic file operations', () => {
it('should parse modified files correctly', () => {
const rawOutput = ':100644 100644 abc123 def456 M\0src/file.ts\0';
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
path: 'src/file.ts',
status: 'M',
shaA: 'abc123',
shaB: 'def456',
});
});
it('should parse added files correctly', () => {
const rawOutput =
':000000 100644 0000000000000000000000000000000000000000 abc123 A\0src/new.ts\0';
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
path: 'src/new.ts',
status: 'A',
shaA: null,
shaB: 'abc123',
});
});
it('should parse deleted files correctly', () => {
const rawOutput =
':100644 000000 abc123 0000000000000000000000000000000000000000 D\0src/old.ts\0';
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
path: 'src/old.ts',
status: 'D',
shaA: 'abc123',
shaB: null,
});
});
it('should parse multiple regular files correctly', () => {
const rawOutput = [
':100644 100644 abc123 def456 M',
'file1.ts',
':100644 100644 111111 222222 M',
'file2.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(2);
expect(result[0].path).toBe('file1.ts');
expect(result[0].status).toBe('M');
expect(result[1].path).toBe('file2.ts');
expect(result[1].status).toBe('M');
});
});
describe('renamed files', () => {
it('should parse renamed file and use NEW path', () => {
// Git diff --raw -z output for a renamed file has TWO paths
// Format: :oldmode newmode oldsha newsha status\0oldpath\0newpath\0
const rawOutput = [
':100644 100644 abc123 def456 R100',
'old/path/file.ts',
'new/path/file.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('new/path/file.ts'); // Should use NEW path
expect(result[0].oldPath).toBe('old/path/file.ts'); // Should preserve old path
expect(result[0].status).toBe('R100');
expect(result[0].shaA).toBe('abc123');
expect(result[0].shaB).toBe('def456');
});
it('should parse renamed file with similarity score', () => {
const rawOutput = [':100644 100644 aaa111 aaa222 R90', 'old.ts', 'new.ts', ''].join('\0');
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('new.ts'); // NEW path
expect(result[0].oldPath).toBe('old.ts'); // OLD path preserved
expect(result[0].status).toBe('R90');
});
it('should handle rename followed by other files', () => {
// Multiple files: rename followed by a modify
const rawOutput = [
':100644 100644 aaa111 aaa222 R90',
'old.ts',
'new.ts',
':100644 100644 bbb111 bbb222 M',
'modified.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
// Should get both files correctly
expect(result).toHaveLength(2);
// First file: renamed
expect(result[0].path).toBe('new.ts'); // NEW path
expect(result[0].oldPath).toBe('old.ts'); // OLD path preserved
expect(result[0].status).toBe('R90');
// Second file: modified (parser should not be misaligned)
expect(result[1].path).toBe('modified.ts');
expect(result[1].status).toBe('M');
});
});
describe('copied files', () => {
it('should parse copied file and use destination path', () => {
// Git diff --raw -z output for a copied file also has TWO paths
// Format: :oldmode newmode oldsha newsha status\0sourcepath\0destpath\0
const rawOutput = [
':100644 100644 abc123 abc123 C100',
'src/original.ts',
'src/copy.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('src/copy.ts'); // Should use DESTINATION path
expect(result[0].oldPath).toBe('src/original.ts'); // Should preserve source path
expect(result[0].status).toBe('C100');
expect(result[0].shaA).toBe('abc123');
expect(result[0].shaB).toBe('abc123');
});
it('should handle copy followed by other files', () => {
const rawOutput = [
':100644 100644 abc123 abc123 C100',
'original.ts',
'copy.ts',
':100644 100644 def456 ghi789 M',
'other.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(2);
// First file: copied
expect(result[0].path).toBe('copy.ts'); // DESTINATION
expect(result[0].oldPath).toBe('original.ts'); // SOURCE
expect(result[0].status).toBe('C100');
// Second file: modified
expect(result[1].path).toBe('other.ts');
expect(result[1].status).toBe('M');
});
});
describe('mixed operations', () => {
it('should handle mix of rename, add, modify, delete', () => {
const rawOutput = [
':100644 100644 aaa111 aaa222 R90',
'old.ts',
'new.ts',
':000000 100644 000000 bbb111 A',
'added.ts',
':100644 100644 ccc111 ccc222 M',
'modified.ts',
':100644 000000 ddd111 000000 D',
'deleted.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
expect(result).toHaveLength(4);
// Renamed file
expect(result[0].path).toBe('new.ts');
expect(result[0].oldPath).toBe('old.ts');
expect(result[0].status).toBe('R90');
// Added file
expect(result[1].path).toBe('added.ts');
expect(result[1].status).toBe('A');
// Modified file
expect(result[2].path).toBe('modified.ts');
expect(result[2].status).toBe('M');
// Deleted file
expect(result[3].path).toBe('deleted.ts');
expect(result[3].status).toBe('D');
});
});
describe('edge cases', () => {
it('should handle empty input', () => {
const result = parseRawDiff('');
expect(result).toHaveLength(0);
});
it('should skip malformed entries', () => {
const rawOutput = [
':100644 100644 abc123 def456 M',
'valid.ts',
':invalid',
'missing-metadata.ts',
'',
].join('\0');
const result = parseRawDiff(rawOutput);
// Should only get the valid entry
expect(result).toHaveLength(1);
expect(result[0].path).toBe('valid.ts');
});
});
});