feat: separate workspaces per parallel execution
This commit is contained in:
12
index.ts
12
index.ts
@@ -18,6 +18,7 @@ import { formatReflections } from "./src/reflection";
|
|||||||
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
||||||
import type { ReviewResult } from "./src/types";
|
import type { ReviewResult } from "./src/types";
|
||||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||||
|
import { cleanupStaleWorktrees } from "./src/worktree";
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
resolveTaskArg,
|
resolveTaskArg,
|
||||||
@@ -256,6 +257,17 @@ async function executePlanBatches(
|
|||||||
autoReview: config.execution.autoReview,
|
autoReview: config.execution.autoReview,
|
||||||
saveReviews: config.execution.saveReviews,
|
saveReviews: config.execution.saveReviews,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up stale worktrees from interrupted runs before starting.
|
||||||
|
if (config.execution.worktrees !== "never" && projectDir) {
|
||||||
|
const removed = cleanupStaleWorktrees(projectDir, progress.getKey());
|
||||||
|
if (removed.length > 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Cleaned up ${removed.length} stale worktree(s) from previous run.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track failed task IDs across batches to block downstream tasks
|
// Track failed task IDs across batches to block downstream tasks
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
verdictGlyph,
|
verdictGlyph,
|
||||||
verdictSummary,
|
verdictSummary,
|
||||||
} from "./review";
|
} from "./review";
|
||||||
|
import { createWorktree, mergeWorktree, removeWorktree } from "./worktree";
|
||||||
import {
|
import {
|
||||||
runAgentSession,
|
runAgentSession,
|
||||||
writeFileSafe,
|
writeFileSafe,
|
||||||
@@ -388,6 +389,20 @@ export async function runTask(
|
|||||||
/**
|
/**
|
||||||
* Execute a batch of tasks (sequentially or in parallel)
|
* Execute a batch of tasks (sequentially or in parallel)
|
||||||
*/
|
*/
|
||||||
|
// ─── Worktree Decision ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Determine if worktree isolation should be used based on config + mode. */
|
||||||
|
function shouldUseWorktrees(config: RalpiConfig, isParallel: boolean): boolean {
|
||||||
|
switch (config.execution.worktrees) {
|
||||||
|
case "always":
|
||||||
|
return true;
|
||||||
|
case "parallel":
|
||||||
|
return isParallel;
|
||||||
|
default:
|
||||||
|
return false; // "never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeBatch(
|
export async function executeBatch(
|
||||||
tasks: Task[],
|
tasks: Task[],
|
||||||
project: Project,
|
project: Project,
|
||||||
@@ -443,6 +458,8 @@ export async function executeBatch(
|
|||||||
const shouldParallel =
|
const shouldParallel =
|
||||||
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
|
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
|
||||||
|
|
||||||
|
const useWorktree = shouldUseWorktrees(config, !!shouldParallel);
|
||||||
|
|
||||||
if (shouldParallel) {
|
if (shouldParallel) {
|
||||||
await executeBatchParallel(
|
await executeBatchParallel(
|
||||||
tasks,
|
tasks,
|
||||||
@@ -453,6 +470,7 @@ export async function executeBatch(
|
|||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
roundRobin,
|
roundRobin,
|
||||||
|
useWorktree,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -468,6 +486,11 @@ export async function executeBatch(
|
|||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
|
undefined, // parallelState
|
||||||
|
undefined, // assignedModel
|
||||||
|
undefined, // roundRobin
|
||||||
|
undefined, // batchRender
|
||||||
|
useWorktree,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Task failed — stop the batch. Dependent tasks are blocked by
|
// Task failed — stop the batch. Dependent tasks are blocked by
|
||||||
@@ -500,6 +523,7 @@ async function executeBatchParallel(
|
|||||||
sendChatMessage?: SendChatMessage,
|
sendChatMessage?: SendChatMessage,
|
||||||
projectDir?: string,
|
projectDir?: string,
|
||||||
roundRobin?: ModelRoundRobin | null,
|
roundRobin?: ModelRoundRobin | null,
|
||||||
|
useWorktree?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const maxParallel = config.execution.maxParallel;
|
const maxParallel = config.execution.maxParallel;
|
||||||
const sharedState: ParallelWidgetState = new Map();
|
const sharedState: ParallelWidgetState = new Map();
|
||||||
@@ -611,6 +635,7 @@ async function executeBatchParallel(
|
|||||||
assignedModel,
|
assignedModel,
|
||||||
roundRobin,
|
roundRobin,
|
||||||
requestBatchRender,
|
requestBatchRender,
|
||||||
|
useWorktree,
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// Safety net: one task failure should never crash the batch.
|
// Safety net: one task failure should never crash the batch.
|
||||||
@@ -668,6 +693,7 @@ async function executeTask(
|
|||||||
assignedModel?: unknown,
|
assignedModel?: unknown,
|
||||||
roundRobin?: ModelRoundRobin | null,
|
roundRobin?: ModelRoundRobin | null,
|
||||||
batchRender?: () => void,
|
batchRender?: () => void,
|
||||||
|
useWorktree?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Model failover: when a provider/API is down, cycle through available models.
|
// Model failover: when a provider/API is down, cycle through available models.
|
||||||
// Pi's built-in retry (via SettingsManager) handles transient errors with
|
// Pi's built-in retry (via SettingsManager) handles transient errors with
|
||||||
@@ -683,6 +709,21 @@ async function executeTask(
|
|||||||
);
|
);
|
||||||
let currentModel: unknown = assignedModel ?? implModel ?? config.model;
|
let currentModel: unknown = assignedModel ?? implModel ?? config.model;
|
||||||
|
|
||||||
|
// ── Worktree isolation ──
|
||||||
|
// When enabled, the task runs in a separate git worktree so parallel tasks
|
||||||
|
// can't stomp each other's files, and review/commit see a clean single-task
|
||||||
|
// diff. `worktreeDir` is used for agent cwd + git ops; `projectDir` stays as
|
||||||
|
// the main repo dir for state saves (reflections, reviews, progress.json).
|
||||||
|
const wt = useWorktree
|
||||||
|
? createWorktree(
|
||||||
|
projectDir,
|
||||||
|
config.paths.stateDir,
|
||||||
|
task.id,
|
||||||
|
progress.getKey(),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const worktreeDir = wt?.dir ?? projectDir;
|
||||||
|
|
||||||
while (modelAttempt < maxModelAttempts) {
|
while (modelAttempt < maxModelAttempts) {
|
||||||
// On subsequent model attempts, advance to the next model.
|
// On subsequent model attempts, advance to the next model.
|
||||||
// Uses advance() instead of assign() so we don't get stuck on
|
// Uses advance() instead of assign() so we don't get stuck on
|
||||||
@@ -714,7 +755,7 @@ async function executeTask(
|
|||||||
depReflections,
|
depReflections,
|
||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
worktreeDir,
|
||||||
parallelState,
|
parallelState,
|
||||||
currentModel,
|
currentModel,
|
||||||
batchRender,
|
batchRender,
|
||||||
@@ -736,9 +777,9 @@ async function executeTask(
|
|||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (hasUncommittedChanges(projectDir)) {
|
while (hasUncommittedChanges(worktreeDir)) {
|
||||||
const status = getGitStatusPorcelain(projectDir);
|
const status = getGitStatusPorcelain(worktreeDir);
|
||||||
const reviewDiff = getGitDiff(projectDir);
|
const reviewDiff = getGitDiff(worktreeDir);
|
||||||
if (!reviewDiff && !status) break;
|
if (!reviewDiff && !status) break;
|
||||||
|
|
||||||
const reviewPrompt = buildReviewPromptUncommitted(
|
const reviewPrompt = buildReviewPromptUncommitted(
|
||||||
@@ -761,7 +802,7 @@ async function executeTask(
|
|||||||
ctx,
|
ctx,
|
||||||
config,
|
config,
|
||||||
reviewPrompt,
|
reviewPrompt,
|
||||||
projectDir,
|
worktreeDir,
|
||||||
`review for ${task.id} · ${task.title}${
|
`review for ${task.id} · ${task.title}${
|
||||||
attempt > 0 ? ` (attempt ${attempt + 1})` : ""
|
attempt > 0 ? ` (attempt ${attempt + 1})` : ""
|
||||||
}`,
|
}`,
|
||||||
@@ -871,7 +912,7 @@ async function executeTask(
|
|||||||
depReflections,
|
depReflections,
|
||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
worktreeDir,
|
||||||
parallelState,
|
parallelState,
|
||||||
currentModel,
|
currentModel,
|
||||||
batchRender,
|
batchRender,
|
||||||
@@ -897,12 +938,12 @@ async function executeTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Commit (after review passes or retries exhausted) ──
|
// ── Commit (after review passes or retries exhausted) ──
|
||||||
if (hasUncommittedChanges(projectDir)) {
|
if (hasUncommittedChanges(worktreeDir)) {
|
||||||
const commitResult = await runCommitSession(
|
const commitResult = await runCommitSession(
|
||||||
ctx,
|
ctx,
|
||||||
config,
|
config,
|
||||||
task,
|
task,
|
||||||
projectDir,
|
worktreeDir,
|
||||||
currentModel,
|
currentModel,
|
||||||
roundRobin,
|
roundRobin,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
@@ -927,12 +968,12 @@ async function executeTask(
|
|||||||
} else if (config.execution.autoCommit) {
|
} else if (config.execution.autoCommit) {
|
||||||
// ── Commit only (no review) — legacy path ──
|
// ── Commit only (no review) — legacy path ──
|
||||||
try {
|
try {
|
||||||
if (hasUncommittedChanges(projectDir)) {
|
if (hasUncommittedChanges(worktreeDir)) {
|
||||||
const commitResult = await runCommitSession(
|
const commitResult = await runCommitSession(
|
||||||
ctx,
|
ctx,
|
||||||
config,
|
config,
|
||||||
task,
|
task,
|
||||||
projectDir,
|
worktreeDir,
|
||||||
currentModel,
|
currentModel,
|
||||||
roundRobin,
|
roundRobin,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
@@ -957,7 +998,7 @@ async function executeTask(
|
|||||||
} else if (config.execution.autoReview) {
|
} else if (config.execution.autoReview) {
|
||||||
// ── Review only (no commit) — reviews latest commit — legacy path ──
|
// ── Review only (no commit) — reviews latest commit — legacy path ──
|
||||||
try {
|
try {
|
||||||
const commitInfo = getLatestCommitDiff(projectDir);
|
const commitInfo = getLatestCommitDiff(worktreeDir);
|
||||||
if (commitInfo && commitInfo.diff) {
|
if (commitInfo && commitInfo.diff) {
|
||||||
const reviewPrompt = buildReviewPrompt(
|
const reviewPrompt = buildReviewPrompt(
|
||||||
task,
|
task,
|
||||||
@@ -980,7 +1021,7 @@ async function executeTask(
|
|||||||
ctx,
|
ctx,
|
||||||
config,
|
config,
|
||||||
reviewPrompt,
|
reviewPrompt,
|
||||||
projectDir,
|
worktreeDir,
|
||||||
`review for ${task.id} · ${task.title}`,
|
`review for ${task.id} · ${task.title}`,
|
||||||
`review-${task.id}`,
|
`review-${task.id}`,
|
||||||
config.execution.reviewTimeoutMs,
|
config.execution.reviewTimeoutMs,
|
||||||
@@ -1057,6 +1098,30 @@ async function executeTask(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Merge worktree back to main ──
|
||||||
|
// After the commit lands on the worktree branch, merge it into the
|
||||||
|
// main repo so downstream tasks see the changes. On conflict, the task
|
||||||
|
// is marked failed and the worktree is retained for inspection.
|
||||||
|
if (wt) {
|
||||||
|
const mergeResult = mergeWorktree(projectDir, wt.branch, task.id);
|
||||||
|
if (!mergeResult.success) {
|
||||||
|
sendChatMessage?.(
|
||||||
|
`✗ ${task.id} · ${task.title} — merge conflict, worktree retained at ${wt.dir}\n ${mergeResult.message}`,
|
||||||
|
);
|
||||||
|
progress.markFailed(task.id, mergeResult.message);
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
roundRobin?.release(task.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Merge succeeded — clean up the worktree.
|
||||||
|
removeWorktree(projectDir, wt);
|
||||||
|
sendChatMessage?.(`✓ merged worktree for ${task.id} into main`);
|
||||||
|
}
|
||||||
|
|
||||||
// Mark completed with all metadata
|
// Mark completed with all metadata
|
||||||
progress.markCompleted(
|
progress.markCompleted(
|
||||||
task.id,
|
task.id,
|
||||||
@@ -1103,6 +1168,7 @@ async function executeTask(
|
|||||||
}`,
|
}`,
|
||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
|
if (wt) removeWorktree(projectDir, wt);
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1118,6 +1184,7 @@ async function executeTask(
|
|||||||
}
|
}
|
||||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
||||||
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
||||||
|
if (wt) removeWorktree(projectDir, wt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1133,6 +1200,7 @@ async function executeTask(
|
|||||||
`Task ${task.id} failed: all configured models exhausted`,
|
`Task ${task.id} failed: all configured models exhausted`,
|
||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
|
if (wt) removeWorktree(projectDir, wt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Save Reflection to File ────────────────────────────────────────────────
|
// ─── Save Reflection to File ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ export interface RalpiConfig {
|
|||||||
reviewBlockOnFail: boolean;
|
reviewBlockOnFail: boolean;
|
||||||
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
|
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
|
||||||
loopTimeoutMs: number;
|
loopTimeoutMs: number;
|
||||||
|
/** Isolate each task in a separate git worktree so parallel tasks can't
|
||||||
|
* stomp each other's files, and review/commit see a clean single-task diff.
|
||||||
|
* - "never": all tasks run in the shared working tree (default, backward compat)
|
||||||
|
* - "parallel": only when maxParallel > 1 and mode is parallel
|
||||||
|
* - "always": every task gets its own worktree */
|
||||||
|
worktrees: "always" | "parallel" | "never";
|
||||||
};
|
};
|
||||||
prompts: {
|
prompts: {
|
||||||
/** Additional context injected into every task prompt */
|
/** Additional context injected into every task prompt */
|
||||||
@@ -272,6 +278,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
|
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
|
||||||
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
|
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
|
||||||
loopTimeoutMs: 0, // 0 = no limit
|
loopTimeoutMs: 0, // 0 = no limit
|
||||||
|
worktrees: "never", // worktree isolation per task
|
||||||
},
|
},
|
||||||
prompts: {
|
prompts: {
|
||||||
projectContext: "",
|
projectContext: "",
|
||||||
|
|||||||
268
src/worktree.ts
Normal file
268
src/worktree.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import * as path from "node:path";
|
||||||
|
import { ensureDir } from "./utils";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface WorktreeHandle {
|
||||||
|
/** Absolute path to the worktree working directory. */
|
||||||
|
dir: string;
|
||||||
|
/** Branch name: `ralpi/<prdKey>/<taskId>`. */
|
||||||
|
branch: string;
|
||||||
|
/** Main repo directory (where the primary working tree lives). */
|
||||||
|
mainDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeResult {
|
||||||
|
success: boolean;
|
||||||
|
/** File paths that conflicted (empty when merge succeeds). */
|
||||||
|
conflicts: string[];
|
||||||
|
/** Human-readable status message. */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Git Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Run a git command, returning trimmed stdout. Returns null on failure. */
|
||||||
|
function git(args: string, cwd: string): string | null {
|
||||||
|
const { execSync } = require("node:child_process") as {
|
||||||
|
execSync: (cmd: string, opts: object) => string;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return execSync(`git ${args}`, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a git command that may fail; returns { ok, stdout, stderr }. */
|
||||||
|
function gitRaw(
|
||||||
|
args: string,
|
||||||
|
cwd: string,
|
||||||
|
): { ok: boolean; stdout: string; stderr: string } {
|
||||||
|
const { execSync } = require("node:child_process") as {
|
||||||
|
execSync: (cmd: string, opts: object) => string;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const stdout = execSync(`git ${args}`, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
return { ok: true, stdout: stdout.trim(), stderr: "" };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stdout: (e.stdout ?? "").toString().trim(),
|
||||||
|
stderr: (e.stderr ?? "").toString().trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a directory is inside a git repository. */
|
||||||
|
export function isGitRepo(dir: string): boolean {
|
||||||
|
return git("rev-parse --git-dir", dir) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current HEAD commit hash of a directory. */
|
||||||
|
export function getGitHead(dir: string): string | null {
|
||||||
|
return git("rev-parse HEAD", dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current branch name of a directory. */
|
||||||
|
export function getCurrentBranch(dir: string): string | null {
|
||||||
|
return git("rev-parse --abbrev-ref HEAD", dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Worktree Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the worktree directory for a given task.
|
||||||
|
* Lives inside `.ralpi/worktrees/<taskId>` in the main repo so all ralpi
|
||||||
|
* state stays co-located. The directory itself is untracked git metadata
|
||||||
|
* (registered in `.git/worktrees/`), so it won't pollute `git status`
|
||||||
|
* in the main working tree.
|
||||||
|
*/
|
||||||
|
export function worktreePath(
|
||||||
|
mainDir: string,
|
||||||
|
stateDir: string,
|
||||||
|
taskId: string,
|
||||||
|
): string {
|
||||||
|
return path.join(mainDir, stateDir, "worktrees", taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a task ID into a valid git branch suffix.
|
||||||
|
* Zero-padded IDs like "01" are already valid; this ensures any stray
|
||||||
|
* characters are replaced.
|
||||||
|
*/
|
||||||
|
function safeBranchSuffix(taskId: string): string {
|
||||||
|
return taskId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a git worktree for a task.
|
||||||
|
*
|
||||||
|
* The worktree is created at `<mainDir>/.ralpi/worktrees/<taskId>` on a new
|
||||||
|
* branch `ralpi/<prdKey>/<taskId>`, based at `baseRef` (defaults to the
|
||||||
|
* current HEAD of `mainDir`).
|
||||||
|
*
|
||||||
|
* Returns null if `mainDir` is not a git repo or the worktree creation fails.
|
||||||
|
*/
|
||||||
|
export function createWorktree(
|
||||||
|
mainDir: string,
|
||||||
|
stateDir: string,
|
||||||
|
taskId: string,
|
||||||
|
prdKey: string,
|
||||||
|
baseRef?: string,
|
||||||
|
): WorktreeHandle | null {
|
||||||
|
if (!isGitRepo(mainDir)) return null;
|
||||||
|
|
||||||
|
const ref = baseRef ?? getGitHead(mainDir);
|
||||||
|
if (!ref) return null;
|
||||||
|
|
||||||
|
const safeId = safeBranchSuffix(taskId);
|
||||||
|
const branch = `ralpi/${prdKey}/${safeId}`;
|
||||||
|
const wtDir = worktreePath(mainDir, stateDir, taskId);
|
||||||
|
|
||||||
|
// Ensure the parent directory exists so `git worktree add` can create
|
||||||
|
// the worktree directory inside it.
|
||||||
|
ensureDir(path.dirname(wtDir));
|
||||||
|
|
||||||
|
// Remove a stale worktree directory if one exists (e.g. from a crashed
|
||||||
|
// previous run). `git worktree add` fails if the path already exists.
|
||||||
|
// We prune first to clean up any metadata for removed-but-not-pruned dirs.
|
||||||
|
git("worktree prune", mainDir);
|
||||||
|
const existing = git(`worktree list --porcelain`, mainDir);
|
||||||
|
if (existing && existing.includes(`worktree ${wtDir}`)) {
|
||||||
|
// A worktree at this path is already registered — remove it.
|
||||||
|
git(`worktree remove --force "${wtDir}"`, mainDir);
|
||||||
|
}
|
||||||
|
// Also delete a stale branch if it exists from a previous run.
|
||||||
|
git(`branch -D "${branch}"`, mainDir);
|
||||||
|
|
||||||
|
const result = gitRaw(
|
||||||
|
`worktree add -b "${branch}" "${wtDir}" "${ref}"`,
|
||||||
|
mainDir,
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
// Fall back to detached HEAD worktree if branch creation fails
|
||||||
|
// (e.g. the branch name somehow conflicts).
|
||||||
|
const fallback = gitRaw(
|
||||||
|
`worktree add --detach "${wtDir}" "${ref}"`,
|
||||||
|
mainDir,
|
||||||
|
);
|
||||||
|
if (!fallback.ok) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dir: wtDir, branch, mainDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a worktree's branch back into the current branch of the main repo.
|
||||||
|
*
|
||||||
|
* Uses `--no-ff` to always create a merge commit, preserving the task
|
||||||
|
* branch's history. On conflict, the merge is aborted and the conflicts
|
||||||
|
* are returned so the caller can mark the task as failed.
|
||||||
|
*/
|
||||||
|
export function mergeWorktree(
|
||||||
|
mainDir: string,
|
||||||
|
branch: string,
|
||||||
|
taskId: string,
|
||||||
|
): MergeResult {
|
||||||
|
// Attempt the merge.
|
||||||
|
const result = gitRaw(`merge --no-ff --no-edit "${branch}"`, mainDir);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
conflicts: [],
|
||||||
|
message: `Merged ${branch} into ${getCurrentBranch(mainDir) ?? "HEAD"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge failed — likely conflicts. Collect the list of conflicting files.
|
||||||
|
const status = git("diff --name-only --diff-filter=U", mainDir) ?? "";
|
||||||
|
const conflicts = status
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Abort the merge so the main repo's working tree is left clean.
|
||||||
|
git("merge --abort", mainDir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
conflicts,
|
||||||
|
message:
|
||||||
|
conflicts.length > 0
|
||||||
|
? `Merge conflicts in: ${conflicts.join(", ")}`
|
||||||
|
: `Merge of ${branch} failed: ${result.stderr || result.stdout}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a worktree and delete its branch.
|
||||||
|
*
|
||||||
|
* Called after a successful merge to clean up. Safe to call even if the
|
||||||
|
* worktree or branch no longer exists.
|
||||||
|
*/
|
||||||
|
export function removeWorktree(mainDir: string, wt: WorktreeHandle): void {
|
||||||
|
git(`worktree remove --force "${wt.dir}"`, mainDir);
|
||||||
|
git(`branch -D "${wt.branch}"`, mainDir);
|
||||||
|
git("worktree prune", mainDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stale worktrees from interrupted runs.
|
||||||
|
*
|
||||||
|
* Lists all worktrees whose branches start with `ralpi/<prdKey>/` and
|
||||||
|
* removes them. Called at the start of a loop to ensure a clean slate.
|
||||||
|
* Returns the list of removed worktree directories.
|
||||||
|
*/
|
||||||
|
export function cleanupStaleWorktrees(
|
||||||
|
mainDir: string,
|
||||||
|
prdKey: string,
|
||||||
|
): string[] {
|
||||||
|
const removed: string[] = [];
|
||||||
|
|
||||||
|
// Prune metadata for worktree directories that no longer exist on disk.
|
||||||
|
git("worktree prune", mainDir);
|
||||||
|
|
||||||
|
const list = git("worktree list --porcelain", mainDir);
|
||||||
|
if (!list) return removed;
|
||||||
|
|
||||||
|
// Parse worktree list: each entry is `worktree <path>` followed by metadata.
|
||||||
|
const wtLines = list
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l.startsWith("worktree "))
|
||||||
|
.map((l) => l.slice("worktree ".length).trim());
|
||||||
|
|
||||||
|
for (const wtDir of wtLines) {
|
||||||
|
// Skip the main working tree (always first in the list).
|
||||||
|
if (path.resolve(wtDir) === path.resolve(mainDir)) continue;
|
||||||
|
|
||||||
|
// Check if this worktree is on a ralpi branch for this PRD.
|
||||||
|
const branch = git(`rev-parse --abbrev-ref HEAD`, wtDir);
|
||||||
|
if (!branch) continue;
|
||||||
|
if (!branch.startsWith(`ralpi/${prdKey}/`)) continue;
|
||||||
|
|
||||||
|
// Remove the worktree and its branch.
|
||||||
|
git(`worktree remove --force "${wtDir}"`, mainDir);
|
||||||
|
if (branch !== "HEAD" && branch !== "detached") {
|
||||||
|
git(`branch -D "${branch}"`, mainDir);
|
||||||
|
}
|
||||||
|
removed.push(wtDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
git("worktree prune", mainDir);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user