/** * 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 { 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 { // 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 { const git = simpleGit(repoPath); const diff = await git.diff([`${baseBranch}...${headBranch}`]); 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 getHeadCommitHash(repoPath: string, branch: string): Promise { const git = simpleGit(repoPath); const result = await git.raw(['rev-parse', branch]); return result.trim(); } async deleteBranch(repoPath: string, branch: string): Promise { 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 { 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 { 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 { 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 { const git = simpleGit(repoPath); return git.diff([`${commitHash}~1`, commitHash]); } async getMergeBase(repoPath: string, branch1: string, branch2: string): Promise { 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 { 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 { 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 " 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 { const git = simpleGit(repoPath); await git.fetch(remote); log.info({ repoPath, remote }, 'fetched remote'); } async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise { 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 { 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'); } }