feat(api): add bevy playtest, worktree review, and drop seeding
- 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
This commit is contained in:
@@ -88,3 +88,130 @@ export function removeWorktree(runId: string, branch: string | null): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user