- bevy.ts: spawn `cargo run` in a run's worktree to playtest its branch, batching build/runtime output as `bevy` events (capped at 2000/run) - events.ts: shared appendRunEvent/nextSeq so the runner, the agent-driven internal mutations, and the bevy launcher all persist through one path - runs.ts: track per-run bevy processes; stop them before worktree teardown - worktrees.ts: review/merge ops (isWorktreePresent, mainDirtySummary, commitsAheadOfHead, diffPatch/stat, mergeBranch) - orchestrator routes: diff, merge, bevy start/stop, bevy-status endpoints - remove seeding: drop seed.ts + data/kanbanCards.ts, the /reset endpoint, and boot-time seeding; cards are plain persisted DB records now
218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
/**
|
|
* Git worktree management for isolated agent runs.
|
|
*
|
|
* Each run optionally gets its own worktree on a dedicated branch off the
|
|
* current `HEAD`. This keeps `main` clean, isolates heavy per-task build
|
|
* artifacts (e.g. the Rust `target/` dir), and lets multiple runs proceed in
|
|
* parallel without clobbering each other's working tree.
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { BRANCH_PREFIX, REPO_ROOT, WORKTREE_ROOT } from './config.js';
|
|
|
|
export interface Worktree {
|
|
path: string;
|
|
branch: string;
|
|
}
|
|
|
|
/** Run a git command, returning trimmed stdout. */
|
|
function git(args: string[], opts?: { cwd?: string }): string {
|
|
return execSync(['git', ...args].join(' '), {
|
|
encoding: 'utf8',
|
|
cwd: opts?.cwd ?? REPO_ROOT,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
}
|
|
|
|
/** Filesystem path for a run's worktree. */
|
|
export function worktreePathFor(runId: string): string {
|
|
return path.join(WORKTREE_ROOT, runId);
|
|
}
|
|
|
|
/** Stable branch name derived from the card id + run id. */
|
|
export function branchNameFor(cardId: string, runId: string): string {
|
|
const safe = cardId.replace(/[^a-z0-9-]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 24);
|
|
return `${BRANCH_PREFIX}/${safe || 'card'}-${runId.slice(0, 8)}`;
|
|
}
|
|
|
|
/**
|
|
* Create a new worktree on a fresh branch off `HEAD`.
|
|
*
|
|
* Throws if the branch already exists or the worktree path is taken; callers
|
|
* are expected to use a unique run id.
|
|
*/
|
|
export function createWorktree(runId: string, cardId: string): Worktree {
|
|
fs.mkdirSync(WORKTREE_ROOT, { recursive: true });
|
|
const wtPath = worktreePathFor(runId);
|
|
const branch = branchNameFor(cardId, runId);
|
|
git(['worktree', 'add', '-b', branch, JSON.stringify(wtPath)]);
|
|
return { path: wtPath, branch };
|
|
}
|
|
|
|
/** Short SHA at the head of a worktree (best-effort). */
|
|
export function headSha(wtPath: string): string | null {
|
|
try {
|
|
return git(['rev-parse', '--short', 'HEAD'], { cwd: wtPath });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Best-effort uncommitted change summary in a worktree (`git status --short`). */
|
|
export function dirtySummary(wtPath: string): string {
|
|
try {
|
|
const out = git(['status', '--short'], { cwd: wtPath });
|
|
if (!out) return '';
|
|
const files = out.split('\n');
|
|
return `${files.length} changed file(s):\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** Remove a worktree and delete its branch (best-effort, never throws). */
|
|
export function removeWorktree(runId: string, branch: string | null): void {
|
|
const wtPath = worktreePathFor(runId);
|
|
try {
|
|
git(['worktree', 'remove', '--force', JSON.stringify(wtPath)]);
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
if (branch) {
|
|
try {
|
|
git(['branch', '-D', branch]);
|
|
} catch {
|
|
/* branch may be checked out elsewhere or already deleted */
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- review & merge (human-triggered) --------------------------------------
|
|
|
|
/** Whether a worktree directory still exists on disk. */
|
|
export function isWorktreePresent(wtPath: string): boolean {
|
|
return fs.existsSync(wtPath);
|
|
}
|
|
|
|
/** Current checked-out branch at the main worktree (REPO_ROOT). */
|
|
export function currentBranch(): string {
|
|
return git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
}
|
|
|
|
/** Empty when the main worktree is clean, else a short summary of changes. */
|
|
export function mainDirtySummary(): string {
|
|
const out = git(['status', '--porcelain']);
|
|
if (!out) return '';
|
|
const files = out.split('\n');
|
|
return `${files.length} uncommitted change(s) in the main worktree:\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`;
|
|
}
|
|
|
|
/** True if `branch` is already contained in HEAD (nothing to merge). */
|
|
export function isMergedIntoHead(branch: string): boolean {
|
|
try {
|
|
git(['merge-base', '--is-ancestor', branch, 'HEAD']);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export interface CommitLog {
|
|
sha: string;
|
|
subject: string;
|
|
}
|
|
|
|
/** Commits on `branch` that are not yet on HEAD (what a merge would bring in). */
|
|
export function commitsAheadOfHead(branch: string): CommitLog[] {
|
|
let out: string;
|
|
try {
|
|
out = git(['log', '--no-decorate', '--pretty=format:%h%x09%s', `HEAD..${branch}`]);
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!out) return [];
|
|
return out.split('\n').map((line) => {
|
|
const idx = line.indexOf('\t');
|
|
return idx >= 0
|
|
? { sha: line.slice(0, idx), subject: line.slice(idx + 1) }
|
|
: { sha: line, subject: '' };
|
|
});
|
|
}
|
|
|
|
/** Compact `--stat` summary of what `branch` changed since it forked from HEAD. */
|
|
export function diffStat(branch: string): string {
|
|
try {
|
|
return git(['diff', '--stat', `HEAD...${branch}`]);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** Full diff of what `branch` changed since it forked from HEAD, size-capped. */
|
|
export function diffPatch(branch: string, maxBytes = 60_000): { patch: string; truncated: boolean } {
|
|
let raw = '';
|
|
try {
|
|
raw = execSync(`git diff HEAD...${branch}`, {
|
|
encoding: 'utf8',
|
|
cwd: REPO_ROOT,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
} catch {
|
|
return { patch: '', truncated: false };
|
|
}
|
|
if (raw.length <= maxBytes) return { patch: raw, truncated: false };
|
|
return { patch: raw.slice(0, maxBytes), truncated: true };
|
|
}
|
|
|
|
export interface MergeResult {
|
|
ok: boolean;
|
|
/** Branch was already an ancestor of HEAD — merge was a no-op. */
|
|
alreadyMerged: boolean;
|
|
/** Branch the merge targeted (the main worktree's checked-out branch). */
|
|
target: string;
|
|
branch: string;
|
|
output: string;
|
|
}
|
|
|
|
/**
|
|
* Merge an agent branch into the main worktree's checked-out branch.
|
|
*
|
|
* Refuses (returns `ok:false`) on conflict, aborting the merge so the main tree
|
|
* is left clean for a manual resolution. Callers should check `mainDirtySummary`
|
|
* first — git will refuse to merge a dirty tree and this returns that as a
|
|
* failure too.
|
|
*/
|
|
export function mergeBranch(branch: string): MergeResult {
|
|
const target = currentBranch();
|
|
if (isMergedIntoHead(branch)) {
|
|
return { ok: true, alreadyMerged: true, target, branch, output: `${branch} is already in ${target}.` };
|
|
}
|
|
try {
|
|
const out = execSync(
|
|
`git merge --no-ff --no-edit -m ${JSON.stringify(`Merge agent branch ${branch} into ${target}`)} ${branch}`,
|
|
{ encoding: 'utf8', cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
return { ok: true, alreadyMerged: false, target, branch, output: out.trim() || `Merged ${branch} into ${target}.` };
|
|
} catch (err) {
|
|
// Abort any in-progress merge so the main worktree stays clean.
|
|
try {
|
|
execSync('git merge --abort', { cwd: REPO_ROOT, stdio: 'ignore' });
|
|
} catch {
|
|
/* not mid-merge */
|
|
}
|
|
const stderr = (err as { stderr?: Buffer }).stderr?.toString().trim() ?? '';
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
ok: false,
|
|
alreadyMerged: false,
|
|
target,
|
|
branch,
|
|
output: stderr || `Merge of ${branch} failed (aborted; ${target} left clean):
|
|
${message}`,
|
|
};
|
|
}
|
|
}
|