/** * 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 } 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 for merge safety const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-')); const repoGit = simpleGit(repoPath); try { // Create ephemeral worktree on target branch await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]); const wtGit = simpleGit(tmpPath); try { await wtGit.merge([sourceBranch, '--no-edit']); 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 */ } } } } 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; } } }