simple-git's .raw() resolves successfully even on exit code 1, returning stdout content. git merge-tree --write-tree outputs CONFLICT markers to stdout (not stderr), so the catch block never fired and conflicts were reported as clean merges.
199 lines
7.3 KiB
TypeScript
199 lines
7.3 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 } 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 } 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<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 {
|
|
// 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<string> {
|
|
const git = simpleGit(repoPath);
|
|
const diff = await git.diff([`${baseBranch}...${headBranch}`]);
|
|
return diff;
|
|
}
|
|
|
|
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);
|
|
await git.push(remote, branch);
|
|
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 };
|
|
}
|
|
}
|