diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 6cf293d..ebf6ea2 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -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), diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 9ba6d85..dc7d174 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -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; + /** + * 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; + + /** + * 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; + /** * Delete a branch. No-op if the branch doesn't exist. */ diff --git a/apps/server/git/simple-git-branch-manager.test.ts b/apps/server/git/simple-git-branch-manager.test.ts index 18cb50a..f59c65b 100644 --- a/apps/server/git/simple-git-branch-manager.test.ts +++ b/apps/server/git/simple-git-branch-manager.test.ts @@ -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; +}> { + 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; @@ -108,3 +171,80 @@ describe('SimpleGitBranchManager', () => { }); }); }); + +describe('SimpleGitBranchManager - diffBranchesStat and diffFileSingle', () => { + let clonePath: string; + let cleanup: () => Promise; + 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'); + }); + }); +}); diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index 47b690e..7e96848 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -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 { const git = simpleGit(repoPath); @@ -97,6 +117,91 @@ export class SimpleGitBranchManager implements BranchManager { return diff; } + async diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise { + 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: "\t\t" + // Binary files: "-\t-\t" + const numStatMap = new Map(); + 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: "\t" or "\t\t" + 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 { + const git = simpleGit(repoPath); + return git.diff([`${baseBranch}...${headBranch}`, '--', filePath]); + } + async deleteBranch(repoPath: string, branch: string): Promise { const git = simpleGit(repoPath); const exists = await this.branchExists(repoPath, branch); diff --git a/apps/server/git/types.ts b/apps/server/git/types.ts index 51a35b7..00cb829 100644 --- a/apps/server/git/types.ts +++ b/apps/server/git/types.ts @@ -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 // ============================================================================= diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md index 2e5d8c4..16968af 100644 --- a/docs/git-process-logging.md +++ b/docs/git-process-logging.md @@ -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/`) |