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 05eb160749
6 changed files with 290 additions and 2 deletions

View File

@@ -46,6 +46,8 @@ function createMocks() {
ensureBranch: vi.fn(),
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }),
diffBranches: vi.fn().mockResolvedValue(''),
diffBranchesStat: vi.fn().mockResolvedValue([]),
diffFileSingle: vi.fn().mockResolvedValue(''),
deleteBranch: vi.fn(),
branchExists: vi.fn().mockResolvedValue(true),
remoteBranchExists: vi.fn().mockResolvedValue(false),

View File

@@ -6,7 +6,7 @@
* a worktree to be checked out.
*/
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit, FileStatEntry } from './types.js';
export interface BranchManager {
/**
@@ -29,6 +29,22 @@ export interface BranchManager {
*/
diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string>;
/**
* Get per-file metadata for changes between two branches.
* Uses three-dot diff (baseBranch...headBranch) — same divergence model as diffBranches.
* Binary files are included with status 'binary' and additions/deletions both 0.
* Does NOT return hunk content.
*/
diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise<FileStatEntry[]>;
/**
* Get the raw unified diff for a single file between two branches.
* Uses three-dot diff (baseBranch...headBranch).
* Returns empty string for binary files (caller must detect binary separately).
* filePath must be URL-decoded before being passed here.
*/
diffFileSingle(repoPath: string, baseBranch: string, headBranch: string, filePath: string): Promise<string>;
/**
* Delete a branch. No-op if the branch doesn't exist.
*/

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');
});
});
});

View File

@@ -11,11 +11,31 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit, FileStatEntry } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager');
/**
* Normalize a numstat path to the new path for rename entries.
* Handles patterns like:
* - "{old.txt => new.txt}" → "new.txt"
* - "dir/{old.txt => new.txt}" → "dir/new.txt"
* - "old_dir/file.txt => new_dir/file.txt" → "new_dir/file.txt"
*/
function normalizeNumstatPath(pathStr: string): string {
const braceMatch = pathStr.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceMatch) {
const [, prefix, , newPart, suffix] = braceMatch;
return `${prefix}${newPart}${suffix}`;
}
const arrowMatch = pathStr.match(/^.* => (.+)$/);
if (arrowMatch) {
return arrowMatch[1];
}
return pathStr;
}
export class SimpleGitBranchManager implements BranchManager {
async ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void> {
const git = simpleGit(repoPath);
@@ -97,6 +117,91 @@ export class SimpleGitBranchManager implements BranchManager {
return diff;
}
async diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise<FileStatEntry[]> {
const git = simpleGit(repoPath);
const range = `${baseBranch}...${headBranch}`;
const [nameStatusRaw, numStatRaw] = await Promise.all([
git.raw(['diff', '--name-status', range]),
git.raw(['diff', '--numstat', range]),
]);
if (!nameStatusRaw.trim()) return [];
// Parse numstat: "<additions>\t<deletions>\t<path>"
// Binary files: "-\t-\t<path>"
const numStatMap = new Map<string, { additions: number; deletions: number; binary: boolean }>();
for (const line of numStatRaw.split('\n')) {
if (!line.trim()) continue;
const tabIdx1 = line.indexOf('\t');
const tabIdx2 = line.indexOf('\t', tabIdx1 + 1);
if (tabIdx1 === -1 || tabIdx2 === -1) continue;
const addStr = line.slice(0, tabIdx1);
const delStr = line.slice(tabIdx1 + 1, tabIdx2);
const pathStr = line.slice(tabIdx2 + 1);
const binary = addStr === '-' && delStr === '-';
// Normalize rename paths like "{old => new}" or "dir/{old => new}/file" to new path
const newPath = normalizeNumstatPath(pathStr);
numStatMap.set(newPath, {
additions: binary ? 0 : parseInt(addStr, 10),
deletions: binary ? 0 : parseInt(delStr, 10),
binary,
});
}
// Parse name-status: "<status>\t<path>" or "<Rxx>\t<oldPath>\t<newPath>"
const entries: FileStatEntry[] = [];
for (const line of nameStatusRaw.split('\n')) {
if (!line.trim()) continue;
const parts = line.split('\t');
if (parts.length < 2) continue;
const statusCode = parts[0];
let status: FileStatEntry['status'];
let filePath: string;
let oldPath: string | undefined;
if (statusCode.startsWith('R')) {
status = 'renamed';
oldPath = parts[1];
filePath = parts[2];
} else if (statusCode === 'A') {
status = 'added';
filePath = parts[1];
} else if (statusCode === 'M') {
status = 'modified';
filePath = parts[1];
} else if (statusCode === 'D') {
status = 'deleted';
filePath = parts[1];
} else {
status = 'modified';
filePath = parts[1];
}
const numStat = numStatMap.get(filePath);
if (numStat?.binary) {
status = 'binary';
}
const entry: FileStatEntry = {
path: filePath,
status,
additions: numStat?.additions ?? 0,
deletions: numStat?.deletions ?? 0,
};
if (oldPath !== undefined) entry.oldPath = oldPath;
entries.push(entry);
}
return entries;
}
async diffFileSingle(repoPath: string, baseBranch: string, headBranch: string, filePath: string): Promise<string> {
const git = simpleGit(repoPath);
return git.diff([`${baseBranch}...${headBranch}`, '--', filePath]);
}
async deleteBranch(repoPath: string, branch: string): Promise<void> {
const git = simpleGit(repoPath);
const exists = await this.branchExists(repoPath, branch);

View File

@@ -100,6 +100,29 @@ export interface BranchCommit {
deletions: number;
}
// =============================================================================
// File Stat Entry (per-file diff metadata)
// =============================================================================
/**
* Metadata for a single file changed between two branches.
* No hunk content — only path, status, and line-count statistics.
*/
export interface FileStatEntry {
/** New path (or old path for deletions) */
path: string;
/** Only set for renames — the path before the rename */
oldPath?: string;
/** Nature of the change */
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
/** Lines added (0 for binary files) */
additions: number;
/** Lines deleted (0 for binary files) */
deletions: number;
/** Which project clone this file belongs to (populated by callers in multi-project scenarios) */
projectId?: string;
}
// =============================================================================
// WorktreeManager Port Interface
// =============================================================================

View File

@@ -40,6 +40,8 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
| `ensureBranch(repoPath, branch, baseBranch)` | Create branch from base if it doesn't exist (idempotent) |
| `mergeBranch(repoPath, source, target)` | Merge via ephemeral worktree, returns conflict info |
| `diffBranches(repoPath, base, head)` | Three-dot diff between branches |
| `diffBranchesStat(repoPath, base, head)` | Per-file metadata (path, status, additions, deletions) — no hunk content. Binary files included with `status: 'binary'` and counts of 0. Returns `FileStatEntry[]`. |
| `diffFileSingle(repoPath, base, head, filePath)` | Raw unified diff for a single file (three-dot diff). `filePath` must be URL-decoded. Returns empty string for binary files. |
| `deleteBranch(repoPath, branch)` | Delete local branch (no-op if missing) |
| `branchExists(repoPath, branch)` | Check local branches |
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |