Files
Codewalkers/apps/server/git/simple-git-branch-manager.ts
Lukas May 346d62ef8d fix: prevent stale duplicate planning tasks from blocking phase completion
Three fixes for phases getting stuck when a detail task crashes and is retried:

1. detailPhase mutation (architect.ts): clean up orphaned pending/in_progress
   detail tasks before creating new ones, preventing duplicates at the source
2. orchestrator recovery: detect and complete stale duplicate planning tasks
   (same category+phase, one completed, one pending)
3. ensureBranch: catch "already exists" TOCTOU race instead of blocking phase
2026-03-06 21:44:26 +01:00

371 lines
14 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, 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<void> {
const git = simpleGit(repoPath);
const exists = await this.branchExists(repoPath, branch);
if (exists) {
log.debug({ repoPath, branch }, 'branch already exists');
return;
}
try {
await git.branch([branch, baseBranch]);
log.info({ repoPath, branch, baseBranch }, 'branch created');
} catch (err) {
// Handle TOCTOU race: branch may have been created between the exists check and now
if (err instanceof Error && err.message.includes('already exists')) {
log.debug({ repoPath, branch }, 'branch created by concurrent process');
return;
}
throw err;
}
}
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 {
// 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<string> {
const git = simpleGit(repoPath);
const diff = await git.diff([`${baseBranch}...${headBranch}`]);
return diff;
}
async diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise<FileStatEntry[]> {
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: "<additions>\t<deletions>\t<path>"
// Binary files: "-\t-\t<path>"
const numStatMap = new Map<string, { additions: number; deletions: number; binary: boolean }>();
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: "<status>\t<path>" or "<Rxx>\t<oldPath>\t<newPath>"
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<string> {
const git = simpleGit(repoPath);
return git.diff([`${baseBranch}...${headBranch}`, '--', filePath]);
}
async getHeadCommitHash(repoPath: string, branch: string): Promise<string> {
const git = simpleGit(repoPath);
const result = await git.raw(['rev-parse', branch]);
return result.trim();
}
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);
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<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 };
}
async fetchRemote(repoPath: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath);
await git.fetch(remote);
log.info({ repoPath, remote }, 'fetched remote');
}
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
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<void> {
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');
}
}