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:
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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>`) |
|
||||
|
||||
Reference in New Issue
Block a user