feat: add diffBranchesStat and diffFileSingle to BranchManager

Adds FileStatEntry type and two new primitives to the BranchManager
port and SimpleGitBranchManager adapter, enabling split diff
procedures in the tRPC layer without returning raw multi-megabyte diffs.

- FileStatEntry captures path, status, additions/deletions, oldPath
  (renames), and optional projectId for multi-project routing
- diffBranchesStat uses --name-status + --numstat, detects binary
  files (shown as - / - in numstat), handles spaces in filenames
- diffFileSingle returns raw unified diff for a single file path
This commit is contained in:
Lukas May
2026-03-06 19:33:47 +01:00
parent 2eccde0ee1
commit 9894cdd06f
6 changed files with 290 additions and 2 deletions

View File

@@ -65,6 +65,69 @@ async function createTestRepoWithRemote(): Promise<{
};
}
/**
* Create a repo pair for testing diff operations.
* Sets up bare + clone with a 'feature' branch that has known changes vs 'main'.
*/
async function createTestRepoForDiff(): Promise<{
clonePath: string;
cleanup: () => Promise<void>;
}> {
const tmpBase = await mkdtemp(path.join(tmpdir(), 'cw-diff-test-'));
const barePath = path.join(tmpBase, 'bare.git');
const workPath = path.join(tmpBase, 'work');
const clonePath = path.join(tmpBase, 'clone');
// Create bare repo
await simpleGit().init([barePath, '--bare']);
// Set up main branch in work dir
await simpleGit().clone(barePath, workPath);
const workGit = simpleGit(workPath);
await workGit.addConfig('user.email', 'test@example.com');
await workGit.addConfig('user.name', 'Test User');
await writeFile(path.join(workPath, 'README.md'), '# README\n');
await writeFile(path.join(workPath, 'to-delete.txt'), 'delete me\n');
await workGit.add(['README.md', 'to-delete.txt']);
await workGit.commit('Initial commit');
await workGit.push('origin', 'main');
// Clone and create feature branch with changes
await simpleGit().clone(barePath, clonePath);
const cloneGit = simpleGit(clonePath);
await cloneGit.addConfig('user.email', 'test@example.com');
await cloneGit.addConfig('user.name', 'Test User');
await cloneGit.checkoutLocalBranch('feature');
// Add new text file
await writeFile(path.join(clonePath, 'added.txt'), 'new content\n');
await cloneGit.add('added.txt');
// Modify existing file
await writeFile(path.join(clonePath, 'README.md'), '# README\n\nModified content\n');
await cloneGit.add('README.md');
// Delete a file
await cloneGit.rm(['to-delete.txt']);
// Add binary file
await writeFile(path.join(clonePath, 'image.bin'), Buffer.alloc(16));
await cloneGit.add('image.bin');
// Add file with space in name
await writeFile(path.join(clonePath, 'has space.txt'), 'content\n');
await cloneGit.add('has space.txt');
await cloneGit.commit('Feature branch changes');
return {
clonePath,
cleanup: async () => {
await rm(tmpBase, { recursive: true, force: true });
},
};
}
describe('SimpleGitBranchManager', () => {
let clonePath: string;
let cleanup: () => Promise<void>;
@@ -108,3 +171,80 @@ describe('SimpleGitBranchManager', () => {
});
});
});
describe('SimpleGitBranchManager - diffBranchesStat and diffFileSingle', () => {
let clonePath: string;
let cleanup: () => Promise<void>;
let branchManager: SimpleGitBranchManager;
beforeEach(async () => {
const setup = await createTestRepoForDiff();
clonePath = setup.clonePath;
cleanup = setup.cleanup;
branchManager = new SimpleGitBranchManager();
});
afterEach(async () => {
await cleanup();
});
describe('diffBranchesStat', () => {
it('returns correct entries for added, modified, and deleted text files', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature');
const added = entries.find(e => e.path === 'added.txt');
const modified = entries.find(e => e.path === 'README.md');
const deleted = entries.find(e => e.path === 'to-delete.txt');
expect(added?.status).toBe('added');
expect(added?.additions).toBeGreaterThan(0);
expect(modified?.status).toBe('modified');
expect(deleted?.status).toBe('deleted');
expect(deleted?.deletions).toBeGreaterThan(0);
});
it('marks binary files as status=binary with additions=0, deletions=0', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature');
const binary = entries.find(e => e.path === 'image.bin');
expect(binary?.status).toBe('binary');
expect(binary?.additions).toBe(0);
expect(binary?.deletions).toBe(0);
});
it('returns empty array when there are no changes', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'main');
expect(entries).toEqual([]);
});
it('handles files with spaces in their names', async () => {
const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature');
const spaced = entries.find(e => e.path === 'has space.txt');
expect(spaced).toBeDefined();
expect(spaced?.status).toBe('added');
});
});
describe('diffFileSingle', () => {
it('returns unified diff containing addition hunks for an added file', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'added.txt');
expect(diff).toContain('+');
expect(diff).toContain('added.txt');
});
it('returns unified diff with removal hunks for a deleted file', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'to-delete.txt');
expect(diff).toContain('-');
expect(diff).toContain('to-delete.txt');
});
it('returns string for a binary file', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'image.bin');
// git diff returns empty or a "Binary files differ" line — no hunk content
expect(typeof diff).toBe('string');
});
it('handles file paths with spaces', async () => {
const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'has space.txt');
expect(diff).toContain('has space.txt');
});
});
});