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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user