From d94bef2fb8e1abc2379e96fb0730879bfe11d692 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sat, 21 Feb 2026 21:09:56 -0800 Subject: [PATCH 1/3] feat: add interactive follow-up loop and improve worktree handling Extract iteration logic into runIterations helper to keep runLoop focused on the interactive prompt shell. After iterations complete, users at a TTY (or inside tmux) are prompted for follow-up tasks instead of the process exiting immediately. Worktree improvements: - Detect when already inside a worktree to avoid nesting - Move PLAN.md from the original cwd into the new worktree - Remove redundant pathExists guard (git handles conflicts) --- src/loop/main.ts | 59 ++++++++----- src/loop/worktree.ts | 34 +++++++- tests/loop/main.test.ts | 6 ++ tests/loop/tmux.test.ts | 1 + tests/loop/worktree.test.ts | 166 ++++++++++++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 23 deletions(-) diff --git a/src/loop/main.ts b/src/loop/main.ts index 16f260d..ead8654 100644 --- a/src/loop/main.ts +++ b/src/loop/main.ts @@ -1,3 +1,4 @@ +import { createInterface } from "node:readline/promises"; import { runDraftPrStep } from "./pr"; import { buildWorkPrompt } from "./prompts"; import { resolveReviewers, runReview } from "./review"; @@ -5,18 +6,18 @@ import { runAgent } from "./runner"; import type { Options } from "./types"; import { hasSignal } from "./utils"; -export const runLoop = async (task: string, opts: Options): Promise => { - const reviewers = resolveReviewers(opts.review, opts.agent); +const runIterations = async ( + task: string, + opts: Options, + reviewers: string[] +): Promise => { let reviewNotes = ""; - console.log(`\n[loop] PLAN.md:\n\n${task}`); - - for (let iteration = 1; iteration <= opts.maxIterations; iteration++) { - const maxLabel = Number.isFinite(opts.maxIterations) + for (let i = 1; i <= opts.maxIterations; i++) { + const tag = Number.isFinite(opts.maxIterations) ? `/${opts.maxIterations}` : ""; - console.log(`\n[loop] iteration ${iteration}${maxLabel}`); - + console.log(`\n[loop] iteration ${i}${tag}`); const prompt = buildWorkPrompt( task, opts.doneSignal, @@ -24,35 +25,30 @@ export const runLoop = async (task: string, opts: Options): Promise => { reviewNotes ); reviewNotes = ""; - const result = await runAgent(opts.agent, prompt, opts); if (result.exitCode !== 0) { throw new Error( `[loop] ${opts.agent} exited with code ${result.exitCode}` ); } - const output = `${result.parsed}\n${result.combined}`; if (!hasSignal(output, opts.doneSignal)) { continue; } - if (reviewers.length === 0) { console.log( `\n[loop] done signal "${opts.doneSignal}" detected, stopping.` ); - return; + return true; } - const review = await runReview(reviewers, task, opts); if (review.approved) { await runDraftPrStep(task, opts); console.log( `\n[loop] done signal "${opts.doneSignal}" detected and review passed, stopping.` ); - return; + return true; } - if (review.consensusFail) { reviewNotes = "Both reviewers requested changes. Decide for each comment whether to address it now. " + @@ -62,12 +58,37 @@ export const runLoop = async (task: string, opts: Options): Promise => { ); continue; } - reviewNotes = review.notes || "Reviewer found more work to do."; console.log("\n[loop] review found more work. continuing loop."); } + return false; +}; - console.log( - `\n[loop] reached max iterations (${opts.maxIterations}), stopping.` - ); +export const runLoop = async (task: string, opts: Options): Promise => { + const reviewers = resolveReviewers(opts.review, opts.agent); + const interactive = process.stdin.isTTY || Boolean(process.env.TMUX); + const rl = interactive + ? createInterface({ input: process.stdin, output: process.stdout }) + : undefined; + let currentTask = task; + while (true) { + const done = await runIterations(currentTask, opts, reviewers); + if (!done) { + console.log( + `\n[loop] reached max iterations (${opts.maxIterations}), stopping.` + ); + } + if (!rl) { + return; + } + const answer = await rl.question( + "\n[loop] follow-up prompt (blank to exit): " + ); + const followUp = answer.trim() || null; + if (!followUp) { + rl.close(); + return; + } + currentTask = `${currentTask}\n\nFollow-up:\n${followUp}`; + } }; diff --git a/src/loop/worktree.ts b/src/loop/worktree.ts index 3a221ea..a7cbeb9 100644 --- a/src/loop/worktree.ts +++ b/src/loop/worktree.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, readdirSync } from "node:fs"; +import { cpSync, existsSync, readdirSync, rmSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { buildLoopName, runGit, sanitizeBase } from "./git"; import type { Options } from "./types"; @@ -14,6 +14,7 @@ interface WorktreeDeps { cwd: () => string; env: NodeJS.ProcessEnv; log: (line: string) => void; + moveFile: (source: string, target: string) => void; pathExists: (path: string) => boolean; runGit: (args: string[]) => GitResult; syncTree: (source: string, target: string) => void; @@ -22,9 +23,11 @@ interface WorktreeDeps { const MAX_WORKTREE_ATTEMPTS = 10_000; const RUN_BASE_ENV = "LOOP_RUN_BASE"; const RUN_ID_ENV = "LOOP_RUN_ID"; +const PLAN_FILE = "PLAN.md"; const WORKTREE_CONFLICT_RE = /already exists|already checked out|not locked/i; const STALE_WORKTREE_RE = /missing but already registered worktree|already registered worktree/i; +const WORKTREE_GIT_DIR_MARKER = ".git/worktrees/"; const isInside = (root: string, path: string): boolean => { const rel = relative(root, path); @@ -103,6 +106,10 @@ const defaultDeps = (): WorktreeDeps => ({ console.log(line); }, pathExists: (path: string) => existsSync(path), + moveFile: (source: string, target: string) => { + cpSync(source, target, { force: true }); + rmSync(source, { force: true }); + }, runGit: (args: string[]) => { try { return runGit(process.cwd(), args); @@ -156,6 +163,24 @@ export const maybeEnterWorktree = ( const deps = { ...defaultDeps(), ...overrides }; const currentCwd = deps.cwd(); + const superProject = deps.runGit([ + "rev-parse", + "--show-superproject-working-tree", + ]); + if (superProject.exitCode === 0 && superProject.stdout.trim()) { + deps.log( + "[loop] already running inside a git worktree, skipping --worktree setup" + ); + return; + } + const gitDir = deps.runGit(["rev-parse", "--git-dir"]).stdout.trim(); + if (gitDir.replaceAll("\\", "/").includes(WORKTREE_GIT_DIR_MARKER)) { + deps.log( + "[loop] already running inside a git worktree, skipping --worktree setup" + ); + return; + } + const repoRoot = gitOutput( deps, ["rev-parse", "--show-toplevel"], @@ -171,9 +196,6 @@ export const maybeEnterWorktree = ( ): { branch: string; worktreePath: string } | undefined => { const candidateBranch = buildWorktreeBranch(runBase, index); const candidatePath = buildWorktreePath(repoRoot, runBase, index); - if (deps.pathExists(candidatePath)) { - return undefined; - } const branchExists = deps.runGit([ @@ -244,6 +266,10 @@ export const maybeEnterWorktree = ( worktreePath, deps.pathExists ); + const sourcePlanPath = join(currentCwd, PLAN_FILE); + if (deps.pathExists(sourcePlanPath)) { + deps.moveFile(sourcePlanPath, join(worktreeCwd, PLAN_FILE)); + } deps.chdir(worktreeCwd); deps.log(`[loop] created worktree "${worktreePath}"`); deps.log(`[loop] switched to branch "${branch}"`); diff --git a/tests/loop/main.test.ts b/tests/loop/main.test.ts index c50f263..7fdbb75 100644 --- a/tests/loop/main.test.ts +++ b/tests/loop/main.test.ts @@ -39,6 +39,12 @@ const loadRunLoop = async (mocks: { runDraftPrStep?: () => Promise; runReview?: () => Promise; }) => { + mock.module("node:readline/promises", () => ({ + createInterface: mock(() => ({ + close: mock(() => undefined), + question: mock(async () => ""), + })), + })); mock.module("../../src/loop/prompts", () => ({ buildWorkPrompt: mock(mocks.buildWorkPrompt ?? (() => "prompt")), })); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 2164a08..f45ff43 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -24,6 +24,7 @@ test("runInTmux returns false when already inside tmux", () => { test("runInTmux throws install message when tmux is missing", () => { expect(() => runInTmux(["--tmux", "--proof", "verify"], { + env: {}, findBinary: () => false, }) ).toThrow(TMUX_MISSING_ERROR); diff --git a/tests/loop/worktree.test.ts b/tests/loop/worktree.test.ts index 2312bd2..741220a 100644 --- a/tests/loop/worktree.test.ts +++ b/tests/loop/worktree.test.ts @@ -28,6 +28,102 @@ test("maybeEnterWorktree is a no-op when --worktree is disabled", () => { expect(gitCalls).toEqual([]); }); +test("maybeEnterWorktree is a no-op when already in a git worktree", () => { + const gitCalls: string[][] = []; + const logs: string[] = []; + + maybeEnterWorktree(makeOptions({ worktree: true }), { + cwd: () => "/repo-loop-1", + log: (line: string) => { + logs.push(line); + }, + runGit: (args: string[]) => { + gitCalls.push(args); + if (args.join(" ") === "rev-parse --show-superproject-working-tree") { + return { + exitCode: 0, + stderr: "", + stdout: "/repo\n", + }; + } + throw new Error(`unexpected git call: ${args.join(" ")}`); + }, + }); + + expect(gitCalls).toEqual([["rev-parse", "--show-superproject-working-tree"]]); + expect(logs).toContain( + "[loop] already running inside a git worktree, skipping --worktree setup" + ); +}); + +test("maybeEnterWorktree is a no-op in linked worktree without superproject path", () => { + const gitCalls: string[][] = []; + const logs: string[] = []; + + maybeEnterWorktree(makeOptions({ worktree: true }), { + cwd: () => "/repo-loop-1", + log: (line: string) => { + logs.push(line); + }, + runGit: (args: string[]) => { + gitCalls.push(args); + if (args.join(" ") === "rev-parse --show-superproject-working-tree") { + return { exitCode: 0, stderr: "", stdout: "" }; + } + if (args.join(" ") === "rev-parse --git-dir") { + return { + exitCode: 0, + stderr: "", + stdout: "/tmp/main/.git/worktrees/repo-loop-1\n", + }; + } + throw new Error(`unexpected git call: ${args.join(" ")}`); + }, + }); + + expect(gitCalls).toEqual([ + ["rev-parse", "--show-superproject-working-tree"], + ["rev-parse", "--git-dir"], + ]); + expect(logs).toContain( + "[loop] already running inside a git worktree, skipping --worktree setup" + ); +}); + +test("maybeEnterWorktree is a no-op when superproject detection fails but git dir is linked", () => { + const gitCalls: string[][] = []; + const logs: string[] = []; + + maybeEnterWorktree(makeOptions({ worktree: true }), { + cwd: () => "/repo-loop-1", + log: (line: string) => { + logs.push(line); + }, + runGit: (args: string[]) => { + gitCalls.push(args); + if (args.join(" ") === "rev-parse --show-superproject-working-tree") { + return { exitCode: 1, stderr: "not a git repo", stdout: "" }; + } + if (args.join(" ") === "rev-parse --git-dir") { + return { + exitCode: 0, + stderr: "", + stdout: ".git/worktrees/repo-loop-2", + }; + } + throw new Error(`unexpected git call: ${args.join(" ")}`); + }, + }); + + expect(gitCalls).toEqual([ + ["rev-parse", "--show-superproject-working-tree"], + ["rev-parse", "--git-dir"], + ]); + expect(logs).toContain( + "[loop] already running inside a git worktree, skipping --worktree setup" + ); +}); + test("maybeEnterWorktree creates and enters worktree #1", () => { const gitCalls: string[][] = []; const chdirs: string[] = []; @@ -61,6 +157,8 @@ test("maybeEnterWorktree creates and enters worktree #1", () => { }); expect(gitCalls).toEqual([ + ["rev-parse", "--show-superproject-working-tree"], + ["rev-parse", "--git-dir"], ["rev-parse", "--show-toplevel"], ["rev-parse", "--verify", "HEAD"], ["show-ref", "--verify", "--quiet", "refs/heads/repo-loop-1"], @@ -71,6 +169,74 @@ test("maybeEnterWorktree creates and enters worktree #1", () => { expect(logs).toContain('[loop] switched to branch "repo-loop-1"'); }); +test("maybeEnterWorktree moves PLAN.md into the worktree root", () => { + const moved: Array<{ source: string; target: string }> = []; + + maybeEnterWorktree(makeOptions({ worktree: true }), { + chdir: (): void => undefined, + cwd: () => "/repo", + env: {}, + log: (): void => undefined, + pathExists: (path: string) => + path === "/repo-loop-1" || path === "/repo/PLAN.md", + runGit: (args: string[]) => { + if (args.join(" ") === "rev-parse --show-toplevel") { + return { exitCode: 0, stderr: "", stdout: "/repo\n" }; + } + if (args.join(" ") === "rev-parse --verify HEAD") { + return { exitCode: 0, stderr: "", stdout: "abc123\n" }; + } + if ( + args.join(" ") === "show-ref --verify --quiet refs/heads/repo-loop-1" + ) { + return { exitCode: 1, stderr: "", stdout: "" }; + } + return { exitCode: 0, stderr: "", stdout: "" }; + }, + moveFile: (source: string, target: string) => { + moved.push({ source, target }); + }, + }); + + expect(moved).toEqual([ + { source: "/repo/PLAN.md", target: "/repo-loop-1/PLAN.md" }, + ]); +}); + +test("maybeEnterWorktree moves PLAN.md into the worktree subpath", () => { + const moved: Array<{ source: string; target: string }> = []; + + maybeEnterWorktree(makeOptions({ worktree: true }), { + chdir: (): void => undefined, + cwd: () => "/repo/src", + env: {}, + log: (): void => undefined, + pathExists: (path: string) => + path === "/repo-loop-1/src" || path === "/repo/src/PLAN.md", + runGit: (args: string[]) => { + if (args.join(" ") === "rev-parse --show-toplevel") { + return { exitCode: 0, stderr: "", stdout: "/repo\n" }; + } + if (args.join(" ") === "rev-parse --verify HEAD") { + return { exitCode: 0, stderr: "", stdout: "abc123\n" }; + } + if ( + args.join(" ") === "show-ref --verify --quiet refs/heads/repo-loop-1" + ) { + return { exitCode: 1, stderr: "", stdout: "" }; + } + return { exitCode: 0, stderr: "", stdout: "" }; + }, + moveFile: (source: string, target: string) => { + moved.push({ source, target }); + }, + }); + + expect(moved).toEqual([ + { source: "/repo/src/PLAN.md", target: "/repo-loop-1/src/PLAN.md" }, + ]); +}); + test("maybeEnterWorktree increments index when branch name is taken", () => { const gitCalls: string[][] = []; From 3cc286563eef4c7cc25dcd9df133cbb5f5b79758 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sat, 21 Feb 2026 21:33:10 -0800 Subject: [PATCH 2/3] Use follow-up commit flow when PR already exists --- src/loop/main.ts | 16 +++++++++++++--- src/loop/pr.ts | 34 +++++++++++++++++++++++----------- tests/loop/main.test.ts | 41 +++++++++++++++++++++++++++++++++++++++-- tests/loop/pr.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/loop/main.ts b/src/loop/main.ts index ead8654..a10a180 100644 --- a/src/loop/main.ts +++ b/src/loop/main.ts @@ -9,7 +9,8 @@ import { hasSignal } from "./utils"; const runIterations = async ( task: string, opts: Options, - reviewers: string[] + reviewers: string[], + hasExistingPr = false ): Promise => { let reviewNotes = ""; console.log(`\n[loop] PLAN.md:\n\n${task}`); @@ -43,7 +44,7 @@ const runIterations = async ( } const review = await runReview(reviewers, task, opts); if (review.approved) { - await runDraftPrStep(task, opts); + await runDraftPrStep(task, opts, hasExistingPr); console.log( `\n[loop] done signal "${opts.doneSignal}" detected and review passed, stopping.` ); @@ -70,14 +71,23 @@ export const runLoop = async (task: string, opts: Options): Promise => { const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : undefined; + let hasExistingPr = false; let currentTask = task; while (true) { - const done = await runIterations(currentTask, opts, reviewers); + const done = await runIterations( + currentTask, + opts, + reviewers, + hasExistingPr + ); if (!done) { console.log( `\n[loop] reached max iterations (${opts.maxIterations}), stopping.` ); } + if (reviewers.length > 0 && done) { + hasExistingPr = true; + } if (!rl) { return; } diff --git a/src/loop/pr.ts b/src/loop/pr.ts index 1dd751f..91560e4 100644 --- a/src/loop/pr.ts +++ b/src/loop/pr.ts @@ -1,21 +1,33 @@ import { runAgent } from "./runner"; import type { Options } from "./types"; -const buildDraftPrPrompt = (task: string): string => - [ - "Create a draft GitHub pull request for the current branch.", - `Task context:\n${task.trim()}`, - "Use `gh pr create --draft` with a clear title and description.", - "If a PR already exists for this branch, do not create another one.", - "Return the PR URL in your final response.", - ].join("\n\n"); +const buildDraftPrPrompt = (task: string, hasExistingPr: boolean): string => + hasExistingPr + ? [ + "A PR already exists for this branch. Send a follow-up commit to it.", + `Task context:\n${task.trim()}`, + "Commit your follow-up changes, push them, and return the commit in your final response.", + ].join("\n\n") + : [ + "Create a draft GitHub pull request for the current branch.", + `Task context:\n${task.trim()}`, + "Use `gh pr create --draft` with a clear title and description.", + "If a PR already exists for this branch, do not create another one.", + "Return the PR URL in your final response.", + ].join("\n\n"); export const runDraftPrStep = async ( task: string, - opts: Options + opts: Options, + hasExistingPr = false ): Promise => { - console.log("\n[loop] review passed. asking model to create draft PR."); - const result = await runAgent(opts.agent, buildDraftPrPrompt(task), opts); + const action = hasExistingPr ? "send commit" : "create draft PR"; + console.log(`\n[loop] review passed. asking model to ${action}.`); + const result = await runAgent( + opts.agent, + buildDraftPrPrompt(task, hasExistingPr), + opts + ); if (result.exitCode !== 0) { throw new Error( `[loop] draft PR ${opts.agent} exited with code ${result.exitCode}` diff --git a/tests/loop/main.test.ts b/tests/loop/main.test.ts index 7fdbb75..c9e4d7d 100644 --- a/tests/loop/main.test.ts +++ b/tests/loop/main.test.ts @@ -38,11 +38,12 @@ const loadRunLoop = async (mocks: { runAgent?: () => Promise; runDraftPrStep?: () => Promise; runReview?: () => Promise; + question?: () => Promise; }) => { mock.module("node:readline/promises", () => ({ createInterface: mock(() => ({ close: mock(() => undefined), - question: mock(async () => ""), + question: mock(async () => mocks.question?.() ?? ""), })), })); mock.module("../../src/loop/prompts", () => ({ @@ -104,7 +105,43 @@ test("runLoop creates draft PR when done signal is reviewed and approved", async expect(runAgent).toHaveBeenCalledTimes(1); expect(runReview).toHaveBeenCalledTimes(1); - expect(runDraftPrStep).toHaveBeenCalledWith("Ship feature", opts); + expect(runDraftPrStep).toHaveBeenNthCalledWith( + 1, + "Ship feature", + opts, + false + ); +}); + +test("runLoop uses follow-up commit prompt after a PR is already created", async () => { + const answers = ["Update docs", ""]; + const { runLoop, runAgent, runReview, runDraftPrStep } = await loadRunLoop({ + resolveReviewers: () => ["codex", "claude"], + runAgent: async () => makeRunResult(""), + runReview: async () => ({ + approved: true, + consensusFail: false, + notes: "", + }), + question: async () => answers.shift() ?? "", + }); + + await runLoop("Ship feature", makeOptions({ review: "claudex" })); + + expect(runAgent).toHaveBeenCalledTimes(2); + expect(runReview).toHaveBeenCalledTimes(2); + expect(runDraftPrStep).toHaveBeenNthCalledWith( + 1, + "Ship feature", + expect.any(Object), + false + ); + expect(runDraftPrStep).toHaveBeenNthCalledWith( + 2, + "Ship feature\n\nFollow-up:\nUpdate docs", + expect.any(Object), + true + ); }); test("runLoop forwards consensus review notes into the next iteration prompt", async () => { diff --git a/tests/loop/pr.test.ts b/tests/loop/pr.test.ts index 664cd17..f0b0892 100644 --- a/tests/loop/pr.test.ts +++ b/tests/loop/pr.test.ts @@ -53,6 +53,29 @@ test("runDraftPrStep prompts model to create draft PR", async () => { expect(passedOpts).toBe(opts); }); +test("runDraftPrStep prompts model to send a follow-up commit when PR exists", async () => { + const { runAgentMock, runDraftPrStep } = await loadRunDraftPrStep( + async () => ({ + combined: "", + exitCode: 0, + parsed: "", + }) + ); + + const opts = makeOptions(); + await runDraftPrStep("Implement feature X", opts, true); + + const [, prompt] = runAgentMock.mock.calls[0] as [ + Options["agent"], + string, + Options, + ]; + + expect(prompt).toContain("A PR already exists for this branch"); + expect(prompt).toContain("follow-up commit"); + expect(prompt).not.toContain("gh pr create --draft"); +}); + test("runDraftPrStep throws when model exits non-zero", async () => { const { runDraftPrStep } = await loadRunDraftPrStep(async () => ({ combined: "", From d736c7d5fae19d8ae268f6e2413217226972129a Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sat, 21 Feb 2026 21:46:01 -0800 Subject: [PATCH 3/3] Address review feedback: clearer log messages, consolidate worktree detection, extract test helper - Differentiate interactive vs non-interactive log when hitting max iterations - Consolidate two separate worktree detection blocks into a single boolean check - Extract makeWorktreeRunGit() helper to reduce duplication in PLAN.md tests --- src/loop/main.ts | 13 ++++++----- src/loop/worktree.ts | 15 +++++++------ tests/loop/worktree.test.ts | 44 ++++++++++++++----------------------- 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/src/loop/main.ts b/src/loop/main.ts index a10a180..e1d89b3 100644 --- a/src/loop/main.ts +++ b/src/loop/main.ts @@ -80,17 +80,20 @@ export const runLoop = async (task: string, opts: Options): Promise => { reviewers, hasExistingPr ); - if (!done) { - console.log( - `\n[loop] reached max iterations (${opts.maxIterations}), stopping.` - ); - } if (reviewers.length > 0 && done) { hasExistingPr = true; } if (!rl) { + if (!done) { + console.log( + `\n[loop] reached max iterations (${opts.maxIterations}), stopping.` + ); + } return; } + if (!done) { + console.log(`\n[loop] reached max iterations (${opts.maxIterations}).`); + } const answer = await rl.question( "\n[loop] follow-up prompt (blank to exit): " ); diff --git a/src/loop/worktree.ts b/src/loop/worktree.ts index a7cbeb9..0a0bfb6 100644 --- a/src/loop/worktree.ts +++ b/src/loop/worktree.ts @@ -167,14 +167,15 @@ export const maybeEnterWorktree = ( "rev-parse", "--show-superproject-working-tree", ]); - if (superProject.exitCode === 0 && superProject.stdout.trim()) { - deps.log( - "[loop] already running inside a git worktree, skipping --worktree setup" - ); - return; + let inWorktree = + superProject.exitCode === 0 && Boolean(superProject.stdout.trim()); + + if (!inWorktree) { + const gitDir = deps.runGit(["rev-parse", "--git-dir"]).stdout.trim(); + inWorktree = gitDir.replaceAll("\\", "/").includes(WORKTREE_GIT_DIR_MARKER); } - const gitDir = deps.runGit(["rev-parse", "--git-dir"]).stdout.trim(); - if (gitDir.replaceAll("\\", "/").includes(WORKTREE_GIT_DIR_MARKER)) { + + if (inWorktree) { deps.log( "[loop] already running inside a git worktree, skipping --worktree setup" ); diff --git a/tests/loop/worktree.test.ts b/tests/loop/worktree.test.ts index 741220a..6ec6e72 100644 --- a/tests/loop/worktree.test.ts +++ b/tests/loop/worktree.test.ts @@ -169,6 +169,20 @@ test("maybeEnterWorktree creates and enters worktree #1", () => { expect(logs).toContain('[loop] switched to branch "repo-loop-1"'); }); +const makeWorktreeRunGit = () => (args: string[]) => { + const cmd = args.join(" "); + if (cmd === "rev-parse --show-toplevel") { + return { exitCode: 0, stderr: "", stdout: "/repo\n" }; + } + if (cmd === "rev-parse --verify HEAD") { + return { exitCode: 0, stderr: "", stdout: "abc123\n" }; + } + if (cmd === "show-ref --verify --quiet refs/heads/repo-loop-1") { + return { exitCode: 1, stderr: "", stdout: "" }; + } + return { exitCode: 0, stderr: "", stdout: "" }; +}; + test("maybeEnterWorktree moves PLAN.md into the worktree root", () => { const moved: Array<{ source: string; target: string }> = []; @@ -179,20 +193,7 @@ test("maybeEnterWorktree moves PLAN.md into the worktree root", () => { log: (): void => undefined, pathExists: (path: string) => path === "/repo-loop-1" || path === "/repo/PLAN.md", - runGit: (args: string[]) => { - if (args.join(" ") === "rev-parse --show-toplevel") { - return { exitCode: 0, stderr: "", stdout: "/repo\n" }; - } - if (args.join(" ") === "rev-parse --verify HEAD") { - return { exitCode: 0, stderr: "", stdout: "abc123\n" }; - } - if ( - args.join(" ") === "show-ref --verify --quiet refs/heads/repo-loop-1" - ) { - return { exitCode: 1, stderr: "", stdout: "" }; - } - return { exitCode: 0, stderr: "", stdout: "" }; - }, + runGit: makeWorktreeRunGit(), moveFile: (source: string, target: string) => { moved.push({ source, target }); }, @@ -213,20 +214,7 @@ test("maybeEnterWorktree moves PLAN.md into the worktree subpath", () => { log: (): void => undefined, pathExists: (path: string) => path === "/repo-loop-1/src" || path === "/repo/src/PLAN.md", - runGit: (args: string[]) => { - if (args.join(" ") === "rev-parse --show-toplevel") { - return { exitCode: 0, stderr: "", stdout: "/repo\n" }; - } - if (args.join(" ") === "rev-parse --verify HEAD") { - return { exitCode: 0, stderr: "", stdout: "abc123\n" }; - } - if ( - args.join(" ") === "show-ref --verify --quiet refs/heads/repo-loop-1" - ) { - return { exitCode: 1, stderr: "", stdout: "" }; - } - return { exitCode: 0, stderr: "", stdout: "" }; - }, + runGit: makeWorktreeRunGit(), moveFile: (source: string, target: string) => { moved.push({ source, target }); },