diff --git a/.agents/ralph/loop.sh b/.agents/ralph/loop.sh index 74dab66..74388ed 100755 --- a/.agents/ralph/loop.sh +++ b/.agents/ralph/loop.sh @@ -11,6 +11,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)" +WORK_DIR="${RALPH_WORKTREE:-$ROOT_DIR}" CONFIG_FILE="${SCRIPT_DIR}/config.sh" DEFAULT_PRD_PATH=".agents/tasks/prd.json" @@ -347,7 +348,7 @@ render_prompt() { local iter="$6" local run_log="$7" local run_meta="$8" - python3 - "$src" "$dst" "$PRD_PATH" "$AGENTS_PATH" "$PROGRESS_PATH" "$ROOT_DIR" "$GUARDRAILS_PATH" "$ERRORS_LOG_PATH" "$ACTIVITY_LOG_PATH" "$GUARDRAILS_REF" "$CONTEXT_REF" "$ACTIVITY_CMD" "$NO_COMMIT" "$story_meta" "$story_block" "$run_id" "$iter" "$run_log" "$run_meta" <<'PY' + python3 - "$src" "$dst" "$PRD_PATH" "$AGENTS_PATH" "$PROGRESS_PATH" "$WORK_DIR" "$GUARDRAILS_PATH" "$ERRORS_LOG_PATH" "$ACTIVITY_LOG_PATH" "$GUARDRAILS_REF" "$CONTEXT_REF" "$ACTIVITY_CMD" "$NO_COMMIT" "$story_meta" "$story_block" "$run_id" "$iter" "$run_log" "$run_meta" <<'PY' import sys from pathlib import Path @@ -802,8 +803,8 @@ write_run_meta() { } git_head() { - if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true + if git -C "$WORK_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$WORK_DIR" rev-parse HEAD 2>/dev/null || true else echo "" fi @@ -813,7 +814,7 @@ git_commit_list() { local before="$1" local after="$2" if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then - git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /' + git -C "$WORK_DIR" log --oneline "$before..$after" | sed 's/^/- /' else echo "" fi @@ -823,15 +824,15 @@ git_changed_files() { local before="$1" local after="$2" if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then - git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /' + git -C "$WORK_DIR" diff --name-only "$before" "$after" | sed 's/^/- /' else echo "" fi } git_dirty_files() { - if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}' + if git -C "$WORK_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$WORK_DIR" status --porcelain | awk '{print "- " $2}' else echo "" fi diff --git a/bin/ralph b/bin/ralph index 6a27885..8b2ffde 100755 --- a/bin/ralph +++ b/bin/ralph @@ -17,6 +17,8 @@ let installForce = false; let prdPath = null; let progressPath = null; let prdOutPath = null; +let worktreeMode = false; +let worktreeName = null; function exists(p) { try { @@ -27,6 +29,121 @@ function exists(p) { } } +function slugify(str) { + return String(str) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function copyEnvFiles(srcDir, destDir) { + let entries; + try { + entries = fs.readdirSync(srcDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + const destPath = path.join(destDir, entry.name); + if (entry.isDirectory()) { + if (["node_modules", ".ralph", ".git"].includes(entry.name)) continue; + copyEnvFiles(srcPath, destPath); + } else if (entry.name.startsWith(".env")) { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.copyFileSync(srcPath, destPath); + } + } +} + +function resetStaleStories(prdFilePath) { + let text, prd; + try { + text = fs.readFileSync(prdFilePath, "utf-8"); + prd = JSON.parse(text); + } catch { + return; + } + const stories = Array.isArray(prd.stories) ? prd.stories : []; + let changed = false; + for (const story of stories) { + if (String(story.status || "").toLowerCase() === "in_progress") { + console.log(`Reset in_progress story ${story.id || "?"} → open`); + story.status = "open"; + story.startedAt = null; + story.completedAt = null; + story.updatedAt = null; + changed = true; + } + } + if (changed) { + fs.writeFileSync(prdFilePath, JSON.stringify(prd, null, 2) + "\n"); + } +} + +function setupWorktree(name, projectDir) { + const worktreeDir = path.join(projectDir, ".ralph", "worktrees", name); + const branchName = "worktree-" + name; + + if (exists(worktreeDir)) { + console.log(`Reusing existing worktree at .ralph/worktrees/${name}`); + console.log("Copying .env files..."); + copyEnvFiles(projectDir, worktreeDir); + return { worktreeDir, branchName }; + } + + console.log(`Creating worktree at .ralph/worktrees/${name}...`); + + // Check if branch already exists + const branchCheck = spawnSync("git", ["rev-parse", "--verify", branchName], { + cwd: projectDir, + stdio: "pipe", + }); + const branchExists = branchCheck.status === 0; + + const gitArgs = branchExists + ? ["worktree", "add", worktreeDir, branchName] + : ["worktree", "add", worktreeDir, "-b", branchName]; + + const addResult = spawnSync("git", gitArgs, { + cwd: projectDir, + stdio: "inherit", + }); + if (addResult.status !== 0) { + console.error("Failed to create git worktree."); + process.exit(1); + } + + console.log("Copying .env files..."); + copyEnvFiles(projectDir, worktreeDir); + + console.log("Installing dependencies..."); + const installResult = spawnSync("npx", ["nypm", "i"], { + cwd: worktreeDir, + stdio: "inherit", + }); + if (installResult.status !== 0) { + console.warn("Warning: dependency install failed. Continuing anyway."); + } + + return { worktreeDir, branchName }; +} + +function printWorktreeNextSteps(name, worktreeDir, branchName, projectDir) { + const relDir = path.relative(projectDir, worktreeDir); + console.log(` +\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 + Worktree ready: ${relDir} + Branch: ${branchName} +\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 + + Next steps: + Create PR: cd ${relDir} && gh pr create + Merge locally: git merge ${branchName} + Cleanup: git worktree remove ${relDir} && git branch -d ${branchName} +`); +} + function readPackageMeta() { try { const pkgPath = path.join(repoRoot, "package.json"); @@ -84,6 +201,7 @@ Options: --out Override PRD output path (prd command) --progress Override progress log path --agent Override agent runner + --worktree [name], -w [name] Run build in a git worktree Notes: - Uses local .agents/ralph if present; otherwise uses bundled defaults. @@ -180,6 +298,23 @@ for (let i = 0; i < rawArgs.length; i += 1) { i += 1; continue; } + if (arg.startsWith("--worktree=")) { + worktreeMode = true; + worktreeName = arg.split("=").slice(1).join("="); + continue; + } + if (arg === "--worktree") { + worktreeMode = true; + continue; + } + if (arg === "-w") { + worktreeMode = true; + if (rawArgs[i + 1] && !rawArgs[i + 1].startsWith("-")) { + worktreeName = rawArgs[i + 1]; + i += 1; + } + continue; + } args.push(arg); } @@ -594,6 +729,29 @@ async function main() { process.exit(1); } env.PRD_PATH = resolvedPrdPath; + resetStaleStories(resolvedPrdPath); + } + + let worktreeDir = null; + let branchName = null; + + if (worktreeMode) { + if (!worktreeName) { + try { + const prdText = fs.readFileSync(env.PRD_PATH, "utf-8"); + const prd = JSON.parse(prdText); + if (prd.project) { + worktreeName = slugify(prd.project); + } + } catch {} + if (!worktreeName) { + worktreeName = "worktree-" + Date.now(); + } + } + const wt = setupWorktree(worktreeName, cwd); + worktreeDir = wt.worktreeDir; + branchName = wt.branchName; + env.RALPH_WORKTREE = worktreeDir; } const result = spawnSync(loopPath, loopArgs, { @@ -601,6 +759,10 @@ async function main() { env, }); + if (worktreeMode && worktreeDir) { + printWorktreeNextSteps(worktreeName, worktreeDir, branchName, cwd); + } + process.exit(result.status ?? 1); }