diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 971bb29..1cad898 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -30,6 +30,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. +- If the user includes `--worktree`, add `--worktree` to the forwarded `task` call. Do not include it in the task text. This runs Codex in an isolated git worktree. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index c92a289..f51516c 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--worktree] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" context: fork allowed-tools: Bash(node:*), AskUserQuestion --- @@ -18,6 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--worktree` is an isolation flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. When present, Codex runs in an isolated git worktree instead of editing the working directory in-place. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 201d1c7..5334d20 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -21,7 +21,8 @@ import { runAppServerTurn } from "./lib/codex.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; -import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; +import { collectReviewContext, ensureGitRepository, getRepoRoot, resolveReviewTarget } from "./lib/git.mjs"; +import { createWorktreeSession, diffWorktreeSession, cleanupWorktreeSession } from "./lib/worktree.mjs"; import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { @@ -59,7 +60,9 @@ import { renderJobStatusReport, renderSetupReport, renderStatusReport, - renderTaskResult + renderTaskResult, + renderWorktreeTaskResult, + renderWorktreeCleanupResult } from "./lib/render.mjs"; const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -77,7 +80,8 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--worktree] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs worktree-cleanup --action [--cwd ]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", " node scripts/codex-companion.mjs cancel [job-id] [--json]" @@ -429,6 +433,7 @@ async function executeReviewRun(request) { async function executeTaskRun(request) { const workspaceRoot = resolveWorkspaceRoot(request.cwd); + const stateRoot = request.stateRoot ? resolveWorkspaceRoot(request.stateRoot) : workspaceRoot; ensureCodexReady(request.cwd); const taskMetadata = buildTaskRunMetadata({ @@ -438,7 +443,7 @@ async function executeTaskRun(request) { let resumeThreadId = null; if (request.resumeLast) { - const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { + const latestThread = await resolveLatestTrackedTaskThread(stateRoot, { excludeJobId: request.jobId }); if (!latestThread) { @@ -498,6 +503,33 @@ async function executeTaskRun(request) { }; } +async function executeTaskRunWithWorktree(request) { + ensureGitRepository(request.cwd); + const session = createWorktreeSession(request.cwd); + + try { + const execution = await executeTaskRun({ + ...request, + cwd: session.worktreePath, + stateRoot: request.cwd + }); + + const diff = diffWorktreeSession(session); + const rendered = renderWorktreeTaskResult(execution, session, diff, { jobId: request.jobId }); + return { + ...execution, + rendered, + payload: { + ...execution.payload, + worktreeSession: session + } + }; + } catch (error) { + cleanupWorktreeSession(session, { keep: false }); + throw error; + } +} + function buildReviewJobMetadata(reviewName, target) { return { kind: reviewName === "Adversarial Review" ? "adversarial-review" : "review", @@ -570,13 +602,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, worktree, resumeLast, jobId }) { return { cwd, model, effort, prompt, write, + worktree, resumeLast, jobId }; @@ -704,7 +737,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + booleanOptions: ["json", "write", "worktree", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" } @@ -722,11 +755,14 @@ async function handleTask(argv) { throw new Error("Choose either --resume/--resume-last or --fresh."); } const write = Boolean(options.write); + const worktree = Boolean(options.worktree); const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast }); + const runTask = write && worktree ? executeTaskRunWithWorktree : executeTaskRun; + if (options.background) { ensureCodexReady(cwd); requireTaskRequest(prompt, resumeLast); @@ -738,6 +774,7 @@ async function handleTask(argv) { effort, prompt, write, + worktree, resumeLast, jobId: job.id }); @@ -750,12 +787,13 @@ async function handleTask(argv) { await runForegroundCommand( job, (progress) => - executeTaskRun({ + runTask({ cwd, model, effort, prompt, write, + worktree, resumeLast, jobId: job.id, onProgress: progress @@ -794,6 +832,7 @@ async function handleTaskWorker(argv) { logFile: storedJob.logFile ?? null } ); + const runTask = request.write && request.worktree ? executeTaskRunWithWorktree : executeTaskRun; await runTrackedJob( { ...storedJob, @@ -801,7 +840,7 @@ async function handleTaskWorker(argv) { logFile }, () => - executeTaskRun({ + runTask({ ...request, onProgress: progress }), @@ -958,6 +997,39 @@ async function handleCancel(argv) { outputCommandResult(payload, renderCancelReport(nextJob), options.json); } +function handleWorktreeCleanup(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["action", "cwd"], + booleanOptions: ["json"] + }); + + const action = options.action; + if (action !== "keep" && action !== "discard") { + throw new Error("Required: --action keep or --action discard."); + } + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const jobId = positionals[0]; + if (!jobId) { + throw new Error("Required: job ID as positional argument."); + } + + const storedJob = readStoredJob(workspaceRoot, jobId); + if (!storedJob) { + throw new Error(`No stored job found for ${jobId}.`); + } + + const session = storedJob.result?.worktreeSession ?? storedJob.payload?.worktreeSession; + if (!session || !session.worktreePath || !session.branch || !session.repoRoot) { + throw new Error(`Job ${jobId} does not have worktree session data. Was it run with --worktree?`); + } + + const result = cleanupWorktreeSession(session, { keep: action === "keep" }); + const rendered = renderWorktreeCleanupResult(action, result, session); + outputCommandResult({ jobId, action, result, session }, rendered, options.json); +} + async function main() { const [subcommand, ...argv] = process.argv.slice(2); if (!subcommand || subcommand === "help" || subcommand === "--help") { @@ -995,6 +1067,9 @@ async function main() { case "cancel": await handleCancel(argv); break; + case "worktree-cleanup": + handleWorktreeCleanup(argv); + break; default: throw new Error(`Unknown subcommand: ${subcommand}`); } diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/codex/scripts/lib/git.mjs index 1c0529a..0784945 100644 --- a/plugins/codex/scripts/lib/git.mjs +++ b/plugins/codex/scripts/lib/git.mjs @@ -187,6 +187,73 @@ function collectBranchContext(cwd, baseRef) { }; } +export function createWorktree(repoRoot) { + const ts = Date.now(); + const worktreesDir = path.join(repoRoot, ".worktrees"); + fs.mkdirSync(worktreesDir, { recursive: true }); + + // Ensure .worktrees/ is excluded from the target repo without modifying tracked files. + // Use git rev-parse to resolve the real git dir (handles linked worktrees where .git is a file). + const rawGitDir = gitChecked(repoRoot, ["rev-parse", "--git-dir"]).stdout.trim(); + const gitDir = path.resolve(repoRoot, rawGitDir); + const excludePath = path.join(gitDir, "info", "exclude"); + const excludeContent = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; + if (!excludeContent.includes(".worktrees")) { + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + fs.appendFileSync(excludePath, `${excludeContent.endsWith("\n") || !excludeContent ? "" : "\n"}.worktrees/\n`); + } + + const worktreePath = path.join(worktreesDir, `codex-${ts}`); + const branch = `codex/${ts}`; + const baseCommit = gitChecked(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); + gitChecked(repoRoot, ["worktree", "add", worktreePath, "-b", branch]); + return { worktreePath, branch, repoRoot, baseCommit, timestamp: ts }; +} + +export function removeWorktree(repoRoot, worktreePath) { + const result = git(repoRoot, ["worktree", "remove", "--force", worktreePath]); + if (result.status !== 0 && !result.stderr.includes("is not a working tree")) { + throw new Error(`Failed to remove worktree: ${result.stderr.trim()}`); + } +} + +export function deleteWorktreeBranch(repoRoot, branch) { + git(repoRoot, ["branch", "-D", branch]); +} + +export function getWorktreeDiff(worktreePath, baseCommit) { + git(worktreePath, ["add", "-A"]); + const result = git(worktreePath, ["diff", "--cached", baseCommit, "--stat"]); + if (result.status !== 0 || !result.stdout.trim()) { + return { stat: "", patch: "" }; + } + const stat = result.stdout.trim(); + const patchResult = gitChecked(worktreePath, ["diff", "--cached", baseCommit]); + return { stat, patch: patchResult.stdout }; +} + +export function applyWorktreePatch(repoRoot, worktreePath, baseCommit) { + git(worktreePath, ["add", "-A"]); + const patchResult = git(worktreePath, ["diff", "--cached", baseCommit]); + if (patchResult.status !== 0 || !patchResult.stdout.trim()) { + return { applied: false, detail: "No changes to apply." }; + } + const patchPath = path.join( + repoRoot, + ".codex-worktree-" + Date.now() + "-" + Math.random().toString(16).slice(2) + ".patch" + ); + try { + fs.writeFileSync(patchPath, patchResult.stdout, "utf8"); + const applyResult = git(repoRoot, ["apply", "--index", patchPath]); + if (applyResult.status !== 0) { + return { applied: false, detail: applyResult.stderr.trim() || "Patch apply failed (conflicts?)." }; + } + return { applied: true, detail: "Changes applied and staged." }; + } finally { + fs.rmSync(patchPath, { force: true }); + } +} + export function collectReviewContext(cwd, target) { const repoRoot = getRepoRoot(cwd); const state = getWorkingTreeState(cwd); diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 0948dbd..5e76c68 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -12,6 +12,8 @@ export function runCommand(command, args = [], options = {}) { windowsHide: true }); + const succeeded = result.status === 0 && result.signal === null; + return { command, args, @@ -19,7 +21,7 @@ export function runCommand(command, args = [], options = {}) { signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null + error: succeeded ? null : result.error ?? null }; } diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec1852..4394ed7 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -445,6 +445,66 @@ export function renderStoredJobResult(job, storedJob) { return `${lines.join("\n").trimEnd()}\n`; } +export function renderWorktreeTaskResult(execution, session, diff, { jobId = null } = {}) { + const lines = []; + + if (execution.rendered) { + lines.push(execution.rendered.trimEnd()); + lines.push(""); + } + + lines.push("---"); + lines.push(""); + lines.push("## Worktree"); + lines.push(""); + lines.push(`Branch: \`${session.branch}\``); + lines.push(`Path: \`${session.worktreePath}\``); + lines.push(""); + + if (diff.stat) { + lines.push("### Changes"); + lines.push(""); + lines.push("```"); + lines.push(diff.stat); + lines.push("```"); + lines.push(""); + } else { + lines.push("Codex made no file changes in the worktree."); + lines.push(""); + } + + lines.push("### Actions"); + lines.push(""); + lines.push("Apply these changes to your working tree, or discard them:"); + lines.push(""); + const resolvedJobId = jobId ?? "JOB_ID"; + const cwdFlag = session.repoRoot ? ` --cwd "${session.repoRoot}"` : ""; + lines.push(`- **Keep**: \`node "\${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" worktree-cleanup ${resolvedJobId} --action keep${cwdFlag}\``); + lines.push(`- **Discard**: \`node "\${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" worktree-cleanup ${resolvedJobId} --action discard${cwdFlag}\``); + + return `${lines.join("\n").trimEnd()}\n`; +} + +export function renderWorktreeCleanupResult(action, result, session) { + const lines = ["# Worktree Cleanup", ""]; + + if (action === "keep") { + if (result.applied) { + lines.push(`Applied changes from \`${session.branch}\` and cleaned up.`); + } else if (result.detail === "No changes to apply.") { + lines.push(`No changes to apply. Worktree and branch \`${session.branch}\` cleaned up.`); + } else { + lines.push(`Failed to apply changes: ${result.detail}`); + lines.push(""); + lines.push(`The worktree and branch \`${session.branch}\` have been preserved at \`${session.worktreePath}\` for manual recovery.`); + } + } else { + lines.push(`Discarded worktree \`${session.worktreePath}\` and branch \`${session.branch}\`.`); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + export function renderCancelReport(job) { const lines = [ "# Codex Cancel", diff --git a/plugins/codex/scripts/lib/worktree.mjs b/plugins/codex/scripts/lib/worktree.mjs new file mode 100644 index 0000000..ad32c9e --- /dev/null +++ b/plugins/codex/scripts/lib/worktree.mjs @@ -0,0 +1,32 @@ +import { + createWorktree, + removeWorktree, + deleteWorktreeBranch, + getWorktreeDiff, + applyWorktreePatch, + ensureGitRepository +} from "./git.mjs"; + +export function createWorktreeSession(cwd) { + const repoRoot = ensureGitRepository(cwd); + return createWorktree(repoRoot); +} + +export function diffWorktreeSession(session) { + return getWorktreeDiff(session.worktreePath, session.baseCommit); +} + +export function cleanupWorktreeSession(session, { keep = false } = {}) { + if (keep) { + const result = applyWorktreePatch(session.repoRoot, session.worktreePath, session.baseCommit); + if (!result.applied && result.detail !== "No changes to apply.") { + return result; + } + removeWorktree(session.repoRoot, session.worktreePath); + deleteWorktreeBranch(session.repoRoot, session.branch); + return result; + } + removeWorktree(session.repoRoot, session.worktreePath); + deleteWorktreeBranch(session.repoRoot, session.branch); + return { applied: false, detail: "Worktree discarded." }; +} diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb..a9c3b76 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -33,6 +33,7 @@ Command selection: - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. +- If the forwarded request includes `--worktree`, pass `--worktree` through to `task`. This runs Codex in an isolated git worktree instead of editing the working directory in-place. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: diff --git a/tests/worktree.test.mjs b/tests/worktree.test.mjs new file mode 100644 index 0000000..81817fe --- /dev/null +++ b/tests/worktree.test.mjs @@ -0,0 +1,247 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createWorktreeSession, + diffWorktreeSession, + cleanupWorktreeSession +} from "../plugins/codex/scripts/lib/worktree.mjs"; +import { getWorktreeDiff } from "../plugins/codex/scripts/lib/git.mjs"; +import { renderWorktreeTaskResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; + +function gitStdout(cwd, args) { + const result = run("git", args, { cwd }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +function commitFile(cwd, fileName = "app.js", contents = "export const value = 1;\n") { + fs.writeFileSync(path.join(cwd, fileName), contents); + assert.equal(run("git", ["add", fileName], { cwd }).status, 0); + const commit = run("git", ["commit", "-m", "init"], { cwd }); + assert.equal(commit.status, 0, commit.stderr); +} + +function createRepoWithInitialCommit() { + const repoRoot = makeTempDir(); + initGitRepo(repoRoot); + commitFile(repoRoot); + return { repoRoot }; +} + +function cleanupSession(session) { + if (!session || !fs.existsSync(session.worktreePath)) { + return; + } + + try { + cleanupWorktreeSession(session, { keep: false }); + } catch { + // Best-effort cleanup for temp test repositories. + } +} + +test("createWorktreeSession returns session with worktreePath, branch, repoRoot, baseCommit", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + assert.equal(session.repoRoot, repoRoot); + assert.match(session.branch, /^codex\/\d+$/); + assert.equal(session.worktreePath, path.join(repoRoot, ".worktrees", `codex-${session.timestamp}`)); + assert.ok(session.baseCommit); + assert.ok(fs.existsSync(session.worktreePath)); + } finally { + cleanupSession(session); + } +}); + +test("createWorktreeSession baseCommit matches repo HEAD at creation time", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const headAtCreation = gitStdout(repoRoot, ["rev-parse", "HEAD"]); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(repoRoot, "app.js"), "export const value = 2;\n"); + assert.equal(run("git", ["add", "app.js"], { cwd: repoRoot }).status, 0); + const commit = run("git", ["commit", "-m", "repo-root change"], { cwd: repoRoot }); + assert.equal(commit.status, 0, commit.stderr); + + const newHead = gitStdout(repoRoot, ["rev-parse", "HEAD"]); + assert.equal(session.baseCommit, headAtCreation); + assert.notEqual(newHead, session.baseCommit); + } finally { + cleanupSession(session); + } +}); + +test("diffWorktreeSession captures uncommitted changes in the worktree", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "app.js"), "export const value = 2;\n"); + + const diff = diffWorktreeSession(session); + + assert.deepEqual(diff, getWorktreeDiff(session.worktreePath, session.baseCommit)); + assert.notEqual(diff.stat, ""); + assert.match(diff.stat, /app\.js/); + } finally { + cleanupSession(session); + } +}); + +test("diffWorktreeSession captures committed changes in the worktree", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "app.js"), "export const value = 2;\n"); + assert.equal(run("git", ["add", "app.js"], { cwd: session.worktreePath }).status, 0); + const commit = run("git", ["commit", "-m", "worktree change"], { cwd: session.worktreePath }); + assert.equal(commit.status, 0, commit.stderr); + + const diff = diffWorktreeSession(session); + + assert.deepEqual(diff, getWorktreeDiff(session.worktreePath, session.baseCommit)); + assert.notEqual(diff.stat, ""); + assert.match(diff.stat, /app\.js/); + } finally { + cleanupSession(session); + } +}); + +test("diffWorktreeSession returns empty when no changes made", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + const diff = diffWorktreeSession(session); + + assert.deepEqual(diff, { stat: "", patch: "" }); + assert.deepEqual(diff, getWorktreeDiff(session.worktreePath, session.baseCommit)); + } finally { + cleanupSession(session); + } +}); + +test("diffWorktreeSession captures new untracked files in the worktree", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "newfile.js"), "export const added = true;\n"); + + const diff = diffWorktreeSession(session); + + assert.notEqual(diff.stat, ""); + assert.match(diff.stat, /newfile\.js/); + assert.match(diff.patch, /added = true/); + } finally { + cleanupSession(session); + } +}); + +test("cleanupWorktreeSession with keep=true applies new untracked files to repoRoot", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "newfile.js"), "export const added = true;\n"); + + const result = cleanupWorktreeSession(session, { keep: true }); + + assert.equal(result.applied, true); + assert.ok(fs.existsSync(path.join(repoRoot, "newfile.js"))); + assert.match(fs.readFileSync(path.join(repoRoot, "newfile.js"), "utf8"), /added = true/); + } finally { + cleanupSession(session); + } +}); + +test("cleanupWorktreeSession with keep=true applies uncommitted worktree changes to repoRoot as staged changes", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "app.js"), "export const value = 2;\n"); + + const result = cleanupWorktreeSession(session, { keep: true }); + const stagedStat = gitStdout(repoRoot, ["diff", "--cached", "--stat"]); + + assert.equal(result.applied, true); + assert.match(stagedStat, /app\.js/); + assert.equal(fs.existsSync(session.worktreePath), false); + } finally { + cleanupSession(session); + } +}); + +test("cleanupWorktreeSession with keep=false discards worktree and returns applied:false", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "app.js"), "export const value = 2;\n"); + + const result = cleanupWorktreeSession(session, { keep: false }); + const stagedStat = gitStdout(repoRoot, ["diff", "--cached", "--stat"]); + + assert.equal(result.applied, false); + assert.match(result.detail, /Worktree discarded\./); + assert.equal(stagedStat, ""); + assert.equal(fs.existsSync(session.worktreePath), false); + } finally { + cleanupSession(session); + } +}); + +test("cleanupWorktreeSession with keep=true preserves worktree when apply fails", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + fs.writeFileSync(path.join(session.worktreePath, "app.js"), "export const value = 2;\n"); + fs.writeFileSync(path.join(repoRoot, "app.js"), "export const value = 3;\n"); + assert.equal(run("git", ["add", "app.js"], { cwd: repoRoot }).status, 0); + + const result = cleanupWorktreeSession(session, { keep: true }); + const branchList = gitStdout(repoRoot, ["branch", "--list", session.branch]); + + assert.equal(result.applied, false); + assert.ok(result.detail); + assert.equal(fs.existsSync(session.worktreePath), true); + assert.match(branchList, new RegExp(session.branch)); + } finally { + cleanupSession(session); + } +}); + +test("renderWorktreeTaskResult includes jobId in cleanup commands when provided", () => { + const output = renderWorktreeTaskResult( + { rendered: "# Task Result\n" }, + { branch: "codex/123", worktreePath: "/tmp/worktree-123" }, + { stat: " app.js | 1 +", patch: "" }, + { jobId: "job-123" } + ); + + assert.match(output, /worktree-cleanup job-123 --action keep/); + assert.match(output, /worktree-cleanup job-123 --action discard/); + assert.doesNotMatch(output, /worktree-cleanup JOB_ID/); +}); + +test("renderWorktreeTaskResult falls back to JOB_ID when jobId is null", () => { + const output = renderWorktreeTaskResult( + { rendered: "" }, + { branch: "codex/123", worktreePath: "/tmp/worktree-123" }, + { stat: "", patch: "" }, + { jobId: null } + ); + + assert.match(output, /worktree-cleanup JOB_ID --action keep/); + assert.match(output, /worktree-cleanup JOB_ID --action discard/); +});