Files
Codewalkers/apps/server/git/simple-git-branch-manager.ts
Lukas May 84250955d1 fix: Show completed phase diffs in review tab
Completed phases showed "No phases pending review" because:
1. Frontend filtered only pending_review phases
2. Server rejected non-pending_review phases
3. After merge, three-dot diff returned empty (merge base moved)

Fix: store pre-merge merge base hash on phase, use it to reconstruct
diffs for completed phases. Frontend now shows both pending_review and
completed phases with read-only mode (Merged badge) for completed ones.
2026-03-05 22:05:28 +01:00

156 lines
5.2 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, 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 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<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');
}
}