Files
Codewalkers/apps/server/git/simple-git-branch-manager.ts
Lukas May 05eb160749 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
2026-03-06 19:36:36 +01:00

356 lines
13 KiB
TypeScript

/**
* SimpleGit BranchManager Adapter
*
* Implementation of BranchManager port using simple-git.
* Performs branch-level operations (create, merge, diff, delete)
* on project clones without requiring a worktree.
*/
import { join, resolve } from 'node:path';
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, 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);
const exists = await this.branchExists(repoPath, branch);
if (exists) {
log.debug({ repoPath, branch }, 'branch already exists');
return;
}
await git.branch([branch, baseBranch]);
log.info({ repoPath, branch, baseBranch }, 'branch created');
}
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
// Use an ephemeral worktree with a temp branch for merge safety.
// We can't check out targetBranch directly — it may already be checked out
// in the clone's main working tree or an agent worktree.
const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
const repoGit = simpleGit(repoPath);
const tempBranch = `cw-merge-${Date.now()}`;
try {
// Capture the target branch ref before merge so callers can roll back on push failure
const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim();
// Create worktree with a temp branch starting at targetBranch's commit
await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]);
const wtGit = simpleGit(tmpPath);
try {
await wtGit.merge([sourceBranch, '--no-edit']);
// Update the real target branch ref to the merge result.
// update-ref bypasses the "branch is checked out" guard.
const mergeCommit = (await wtGit.revparse(['HEAD'])).trim();
await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]);
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef };
} catch (mergeErr) {
// Check for merge conflicts
const status = await wtGit.status();
const conflicts = status.conflicted;
if (conflicts.length > 0) {
log.warn({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge conflicts detected');
await wtGit.merge(['--abort']);
return {
success: false,
conflicts,
message: `Merge conflicts in ${conflicts.length} file(s)`,
};
}
// Non-conflict merge failure
throw mergeErr;
}
} finally {
// Clean up ephemeral worktree
try {
await repoGit.raw(['worktree', 'remove', tmpPath, '--force']);
} catch {
// Best-effort cleanup — force-remove the directory if worktree remove fails
try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
}
// Delete the temp branch
try {
await repoGit.raw(['branch', '-D', tempBranch]);
} catch { /* ignore — may already be cleaned up */ }
}
}
async diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string> {
const git = simpleGit(repoPath);
const diff = await git.diff([`${baseBranch}...${headBranch}`]);
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);
if (!exists) return;
try {
await git.deleteLocalBranch(branch, true);
log.info({ repoPath, branch }, 'branch deleted');
} catch (err) {
log.warn({ repoPath, branch, err: err instanceof Error ? err.message : String(err) }, 'failed to delete branch');
}
}
async branchExists(repoPath: string, branch: string): Promise<boolean> {
const git = simpleGit(repoPath);
try {
const branches = await git.branchLocal();
return branches.all.includes(branch);
} catch {
return false;
}
}
async remoteBranchExists(repoPath: string, branch: string): Promise<boolean> {
try {
const git = simpleGit(repoPath);
const result = await git.branch(['-r']);
return result.all.some(
(ref) => ref === `origin/${branch}` || ref === `remotes/origin/${branch}`,
);
} catch {
return false;
}
}
async listCommits(repoPath: string, baseBranch: string, headBranch: string): Promise<BranchCommit[]> {
const git = simpleGit(repoPath);
const logResult = await git.log({ from: baseBranch, to: headBranch, '--stat': null });
return logResult.all.map((entry) => {
const diffStat = entry.diff;
return {
hash: entry.hash,
shortHash: entry.hash.slice(0, 7),
message: entry.message,
author: entry.author_name,
date: entry.date,
filesChanged: diffStat?.files?.length ?? 0,
insertions: diffStat?.insertions ?? 0,
deletions: diffStat?.deletions ?? 0,
};
});
}
async diffCommit(repoPath: string, commitHash: string): Promise<string> {
const git = simpleGit(repoPath);
return git.diff([`${commitHash}~1`, commitHash]);
}
async getMergeBase(repoPath: string, branch1: string, branch2: string): Promise<string> {
const git = simpleGit(repoPath);
const result = await git.raw(['merge-base', branch1, branch2]);
return result.trim();
}
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath);
try {
await git.push(remote, branch);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('branch is currently checked out')) throw err;
// Local non-bare repo with the branch checked out — temporarily allow it.
// receive.denyCurrentBranch=updateInstead updates the remote's working tree
// and index to match, or rejects if the working tree is dirty.
const remoteUrl = (await git.remote(['get-url', remote]))?.trim();
if (!remoteUrl) throw err;
const remotePath = resolve(repoPath, remoteUrl);
const remoteGit = simpleGit(remotePath);
await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead');
try {
await git.push(remote, branch);
} finally {
await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']);
}
}
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
}
async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult> {
const git = simpleGit(repoPath);
// git merge-tree --write-tree outputs everything to stdout.
// simple-git's .raw() resolves with stdout even on exit code 1 (conflicts),
// so we parse the output text instead of relying on catch.
const output = await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]);
// Parse conflict file names from "CONFLICT (content): Merge conflict in <path>"
const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g;
const conflicts: string[] = [];
let match: RegExpExecArray | null;
while ((match = conflictPattern.exec(output)) !== null) {
conflicts.push(match[1].trim());
}
if (conflicts.length > 0) {
log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts');
return { mergeable: false, conflicts };
}
// Fallback: check for any CONFLICT text we couldn't parse specifically
if (output.includes('CONFLICT')) {
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts');
return { mergeable: false, conflicts: ['(unable to parse conflict details)'] };
}
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean');
return { mergeable: true };
}
async fetchRemote(repoPath: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath);
await git.fetch(remote);
log.info({ repoPath, remote }, 'fetched remote');
}
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath);
const remoteBranch = `${remote}/${branch}`;
// Verify it's a genuine fast-forward (branch is ancestor of remote)
try {
await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]);
} catch {
throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`);
}
// Use update-ref instead of git merge so dirty working trees don't block it.
// The clone may have uncommitted agent work; we only need to advance the ref.
const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim();
await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]);
log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch');
}
async updateRef(repoPath: string, branch: string, commitHash: string): Promise<void> {
const git = simpleGit(repoPath);
await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]);
log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated');
}
}