Files
Codewalkers/apps/server/git/simple-git-branch-manager.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

251 lines
9.7 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 } 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 {
// 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 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');
}
}