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
251 lines
9.7 KiB
TypeScript
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');
|
|
}
|
|
}
|