feat: per prd worktrees

This commit is contained in:
2026-07-22 16:37:02 -04:00
parent 0034272c54
commit 9fcf944cb5
2 changed files with 29 additions and 10 deletions

View File

@@ -260,7 +260,11 @@ async function executePlanBatches(
// Clean up stale worktrees from interrupted runs before starting. // Clean up stale worktrees from interrupted runs before starting.
if (config.execution.worktrees !== "never" && projectDir) { if (config.execution.worktrees !== "never" && projectDir) {
const removed = cleanupStaleWorktrees(projectDir, config.paths.stateDir); const removed = cleanupStaleWorktrees(
projectDir,
config.paths.stateDir,
progress.getKey(),
);
if (removed.length > 0) { if (removed.length > 0) {
ctx.ui.notify( ctx.ui.notify(
`Cleaned up ${removed.length} stale worktree(s) from previous run.`, `Cleaned up ${removed.length} stale worktree(s) from previous run.`,

View File

@@ -86,17 +86,19 @@ export function getCurrentBranch(dir: string): string | null {
/** /**
* Path to the worktree directory for a given task. * Path to the worktree directory for a given task.
* Lives inside `.ralpi/worktrees/<taskId>` in the main repo so all ralpi * Lives inside `.ralpi/worktrees/<prdKey>/<taskId>` in the main repo so all
* state stays co-located. The directory itself is untracked git metadata * ralpi state stays co-located and multiple loops (different PRDs) can run
* (registered in `.git/worktrees/`), so it won't pollute `git status` * concurrently without colliding on shared task IDs. The directory itself
* in the main working tree. * is untracked git metadata (registered in `.git/worktrees/`), so it won't
* pollute `git status` in the main working tree.
*/ */
export function worktreePath( export function worktreePath(
mainDir: string, mainDir: string,
stateDir: string, stateDir: string,
prdKey: string,
taskId: string, taskId: string,
): string { ): string {
return path.join(mainDir, stateDir, "worktrees", taskId); return path.join(mainDir, stateDir, "worktrees", prdKey, taskId);
} }
/** /**
@@ -129,8 +131,8 @@ function slugifyTitle(title: string): string {
/** /**
* Create a git worktree for a task. * Create a git worktree for a task.
* *
* The worktree is created at `<mainDir>/.ralpi/worktrees/<taskId>` on a new * The worktree is created at `<mainDir>/.ralpi/worktrees/<prdKey>/<taskId>`
* branch. When `taskTitle` is provided the branch name is the slugified title * on a new branch. When `taskTitle` is provided the branch name is the slugified title
* alone (e.g. `fix-plans-tab-grammar-casing-icons`); otherwise it falls back * alone (e.g. `fix-plans-tab-grammar-casing-icons`); otherwise it falls back
* to `ralpi/<prdKey>/<taskId>`. Based at `baseRef` (defaults to the current * to `ralpi/<prdKey>/<taskId>`. Based at `baseRef` (defaults to the current
* HEAD of `mainDir`). * HEAD of `mainDir`).
@@ -157,7 +159,7 @@ export function createWorktree(
const safeId = safeBranchSuffix(taskId); const safeId = safeBranchSuffix(taskId);
const slug = taskTitle ? slugifyTitle(taskTitle) : ""; const slug = taskTitle ? slugifyTitle(taskTitle) : "";
const branch = slug || `ralpi/${prdKey}/${safeId}`; const branch = slug || `ralpi/${prdKey}/${safeId}`;
const wtDir = worktreePath(mainDir, stateDir, taskId); const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId);
// Ensure the parent directory exists so `git worktree add` can create // Ensure the parent directory exists so `git worktree add` can create
// the worktree directory inside it. // the worktree directory inside it.
@@ -301,10 +303,16 @@ export function removeWorktree(mainDir: string, wt: WorktreeHandle): void {
* `<mainDir>/<stateDir>/worktrees/` and removes them. Called at the start * `<mainDir>/<stateDir>/worktrees/` and removes them. Called at the start
* of a loop to ensure a clean slate. Returns the list of removed worktree * of a loop to ensure a clean slate. Returns the list of removed worktree
* directories. * directories.
*
* When `prdKey` is provided, cleanup is scoped to
* `<mainDir>/<stateDir>/worktrees/<prdKey>/` so that worktrees belonging to
* other concurrently running loops (different PRDs) are left untouched.
* When omitted, all ralpi-managed worktrees are cleaned.
*/ */
export function cleanupStaleWorktrees( export function cleanupStaleWorktrees(
mainDir: string, mainDir: string,
stateDir: string, stateDir: string,
prdKey?: string,
): string[] { ): string[] {
const removed: string[] = []; const removed: string[] = [];
@@ -315,7 +323,14 @@ export function cleanupStaleWorktrees(
if (!list) return removed; if (!list) return removed;
// Worktrees we manage live under <mainDir>/<stateDir>/worktrees/. // Worktrees we manage live under <mainDir>/<stateDir>/worktrees/.
const managedRoot = path.resolve(mainDir, stateDir, "worktrees"); // When a prdKey is given, narrow to that PRD's subdir so concurrent
// loops (other PRDs) are not disturbed.
const managedRoot = path.resolve(
mainDir,
stateDir,
"worktrees",
...(prdKey ? [prdKey] : []),
);
// Parse worktree list: each entry is `worktree <path>` followed by metadata. // Parse worktree list: each entry is `worktree <path>` followed by metadata.
const wtLines = list const wtLines = list