/** * 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 } 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, BranchCommit } from './types.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('branch-manager'); 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 { // 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}` }; } 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 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); await git.push(remote, branch); log.info({ repoPath, branch, remote }, 'branch pushed to remote'); } }