diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/codex/scripts/lib/git.mjs index c401f0ca..e25f884e 100644 --- a/plugins/codex/scripts/lib/git.mjs +++ b/plugins/codex/scripts/lib/git.mjs @@ -297,6 +297,113 @@ function buildAdversarialCollectionGuidance(options = {}) { return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings."; } +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) { + // Locale-safe: git's "is not a working tree" message is translated on non-English + // locales (e.g. ru_RU: "не является рабочим каталогом"), so never match stderr text. + // If the worktree is already gone (prior cleanup, rm, crash), treat it as success. + const canonical = (p) => { + try { return fs.realpathSync(p); } catch { return path.resolve(p); } + }; + const target = canonical(worktreePath); + const listed = git(repoRoot, ["worktree", "list", "--porcelain"]).stdout + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => canonical(line.slice("worktree ".length).trim())); + if (!listed.includes(target)) { + return; // already removed — desired end-state reached + } + const result = git(repoRoot, ["worktree", "remove", "--force", worktreePath]); + if (result.status !== 0) { + throw new Error(`Failed to remove worktree: ${result.stderr.trim()}`); + } +} + +export function deleteWorktreeBranch(repoRoot, branch) { + git(repoRoot, ["branch", "-D", branch]); +} + +// Detect uncommitted work that `git add -A` skipped because it is git-ignored +// (e.g. .env, dist/, build artifacts Codex wrote). A worktree whose only changes +// are ignored would produce an empty patch and be mistaken for "no changes" — +// force-removing it would irreversibly destroy that work. Returns true if any +// ignored file in the worktree differs from the base worktree state. +export function hasIgnoredChanges(worktreePath) { + // --porcelain: !! marks ignored entries. Only ignored files that exist as + // working-tree content count; an empty worktree (fresh from HEAD) lists none. + const result = git(worktreePath, ["status", "--porcelain", "--ignored", "-uall"]); + if (result.status !== 0) { + return false; + } + return result.stdout.split("\n").some((line) => line.startsWith("!! ") && line.length > 3); +} + +export function getWorktreeDiff(worktreePath, baseCommit) { + // gitChecked (not git): a failed snapshot (e.g. index lock held by a concurrent + // git op) must throw, not be classified as "no changes" — otherwise callers would + // treat real work as empty and discard the only copy. + gitChecked(worktreePath, ["add", "-A"]); + const result = git(worktreePath, ["diff", "--cached", baseCommit, "--stat"]); + if (result.status !== 0) { + throw new Error(`Failed to diff worktree: ${result.stderr.trim()}`); + } + if (!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) { + gitChecked(worktreePath, ["add", "-A"]); + const patchPath = path.join( + repoRoot, + ".codex-worktree-" + Date.now() + "-" + Math.random().toString(16).slice(2) + ".patch" + ); + try { + // Write the patch via `git diff --output=` so the bytes flow directly + // git→file, never through a JS string. runCommand decodes stdout as UTF-8, + // which would replace invalid bytes (0xff, NUL, legacy encodings) with + // U+FFFD and then apply a corrupted patch as "success" — destroying the + // only correct copy in the worktree. Detect empty via file size instead. + const diffResult = gitChecked(worktreePath, ["diff", "--cached", "--binary", baseCommit, "--output", patchPath]); + void diffResult; + if (!fs.existsSync(patchPath) || fs.statSync(patchPath).size === 0) { + return { applied: false, detail: "No changes to apply." }; + } + 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, options = {}) { const repoRoot = getRepoRoot(cwd); const currentBranch = getCurrentBranch(repoRoot); diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 01fbbfe9..a52b94da 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -13,14 +13,23 @@ export function runCommand(command, args = [], options = {}) { windowsHide: true }); + const succeeded = result.status === 0 && result.signal === null && !result.error; + // A child that was killed by a signal OR failed to spawn (ENOENT, EAGAIN, + // maxBuffer) returns status:null. Treat any of those as a non-zero exit so + // callers that check status===0 don't mistake an abnormal git op (killed + // mid-run, missing binary) for success — for worktree cleanup that would mean + // deleting the only copy of unapplied changes. + const failed = result.signal !== null || result.error; + const status = failed ? 1 : result.status ?? 0; + return { command, args, - status: result.status ?? 0, + status, 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 2ec18523..4394ed7c 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 00000000..9b6feab0 --- /dev/null +++ b/plugins/codex/scripts/lib/worktree.mjs @@ -0,0 +1,43 @@ +import { + createWorktree, + removeWorktree, + deleteWorktreeBranch, + getWorktreeDiff, + applyWorktreePatch, + hasIgnoredChanges, + 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; + } + // "No changes to apply" via `git add -A` skips git-ignored paths. If the + // worktree holds ignored-only work (e.g. Codex wrote .env, dist/, build + // artifacts), force-removing it would irreversibly destroy it. Preserve the + // worktree + branch and surface the situation instead. (openai#137 review.) + if (!result.applied && hasIgnoredChanges(session.worktreePath)) { + return { + applied: false, + detail: `No tracked changes to apply, but the worktree has ignored files (e.g. .env, dist/) at ${session.worktreePath}. Preserving the worktree and branch ${session.branch} — inspect and copy them manually before removing.` + }; + } + 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/tests/process.test.mjs b/tests/process.test.mjs index c9b8390f..cc6367b6 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; +import { terminateProcessTree, runCommand } from "../plugins/codex/scripts/lib/process.mjs"; test("terminateProcessTree uses taskkill on Windows", () => { let captured = null; @@ -84,3 +84,26 @@ test("terminateProcessTree recognizes a missing Windows process with localized o assert.equal(outcome.delivered, false); assert.equal(outcome.method, "taskkill"); }); + +// Regression: a child killed by signal has status:null from spawnSync. runCommand +// must NOT normalize that to status:0 — callers checking status===0 would mistake a +// signal-terminated process (e.g. git apply killed mid-run) for success, which for +// worktree cleanup means deleting the only copy of unapplied changes (openai#137 +// review finding). Signal-terminated => status != 0. +test("runCommand reports a signal-terminated child as failed (non-zero status)", () => { + // `node -e 'process.kill(process.pid, "SIGKILL")'` terminates itself with a signal. + const result = runCommand(process.execPath, ["-e", "process.kill(process.pid, 'SIGKILL')"]); + assert.ok(result.signal !== null, `expected a signal, got signal=${result.signal}`); + assert.notEqual(result.status, 0, `signal-terminated child must not report status 0, got ${result.status}`); +}); + +// Regression: a child that fails to spawn (ENOENT — missing binary) returns +// status:null, signal:null, error:ENOENT from spawnSync. runCommand must NOT +// normalize that to status:0 — callers checking status===0 would mistake a +// missing/failed git op for success, which for worktree cleanup destroys the +// only copy of unapplied changes (openai#137 cycle-2 review finding). +test("runCommand reports a spawn failure (ENOENT) as failed (non-zero status)", () => { + const result = runCommand("/nonexistent-codex-binary-xyz", []); + assert.ok(result.error, `expected an error, got ${result.error}`); + assert.notEqual(result.status, 0, `spawn failure must not report status 0, got ${result.status}`); +}); diff --git a/tests/worktree.test.mjs b/tests/worktree.test.mjs new file mode 100644 index 00000000..df47ddf9 --- /dev/null +++ b/tests/worktree.test.mjs @@ -0,0 +1,324 @@ +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, removeWorktree } 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 { + // macOS symlinks /var → /private/var; git canonicalizes repoRoot while + // makeTempDir returns the symlinked path. Compare via realpath like #497. + assert.equal(fs.realpathSync(session.repoRoot), fs.realpathSync(repoRoot)); + assert.match(session.branch, /^codex\/\d+$/); + assert.equal(fs.realpathSync(session.worktreePath), fs.realpathSync(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/); +}); + +// Regression: removeWorktree must not throw when the worktree is already gone +// (prior cleanup, rm, crash). The old guard matched git's "is not a working tree" +// stderr text, which is locale-translated (ru_RU: "не является рабочим каталогом") +// — on non-English locales it threw, leaking the codex/ branch forever. +// Locale-safe: check git worktree list, no-op if the path is not registered. +test("removeWorktree is a no-op (no throw) when the worktree is already removed", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + // Remove it once via the session helper path, then call removeWorktree again + // against the now-gone path — must not throw. + removeWorktree(repoRoot, session.worktreePath); + assert.doesNotThrow(() => removeWorktree(repoRoot, session.worktreePath)); +}); + +// Regression: the keep patch path must be byte-preserving. runCommand decodes +// stdout as UTF-8, which corrupts invalid bytes (0xff, NUL, legacy encodings) +// into U+FFFD. applyWorktreePatch now writes the patch via `git diff --output` +// (git→file, no JS string), so binary/non-UTF-8 changes round-trip exactly — +// otherwise keep would apply a corrupted patch as "success" then destroy the +// only correct copy in the worktree (openai#137 cycle-2 review finding). +test("cleanupWorktreeSession keep round-trips a non-UTF-8 byte (0xff) without corruption", () => { + const { repoRoot } = createRepoWithInitialCommit(); + const session = createWorktreeSession(repoRoot); + + try { + // Write a file containing a raw 0xff byte in the worktree. + fs.writeFileSync(path.join(session.worktreePath, "binary.bin"), Buffer.from([0xff, 0x00, 0x41, 0xff])); + + const result = cleanupWorktreeSession(session, { keep: true }); + assert.equal(result.applied, true); + + const applied = fs.readFileSync(path.join(repoRoot, "binary.bin")); + assert.deepEqual( + Array.from(applied), + [0xff, 0x00, 0x41, 0xff], + `binary bytes must round-trip exactly; got ${Array.from(applied)} (U+FFFD corruption would show 0xEF 0xBF 0xBD)` + ); + } finally { + cleanupSession(session); + } +}); + +// Regression: "No changes to apply" must not force-remove a worktree that holds +// ignored-only work. `git add -A` skips git-ignored paths (.env, dist/, build +// artifacts), so an ignored-only worktree produces an empty patch. Before the +// fix, keep would force-remove it and lose the artifacts irreversibly. Now the +// worktree + branch are preserved and the result surfaces the situation. +test("cleanupWorktreeSession keep preserves the worktree when only ignored files changed", () => { + const { repoRoot } = createRepoWithInitialCommit(); + // Make this test repo ignore dist/ so the worktree's artifact is ignored. + fs.writeFileSync(path.join(repoRoot, ".gitignore"), "dist/\n"); + assert.equal(run("git", ["add", ".gitignore"], { cwd: repoRoot }).status, 0); + assert.equal(run("git", ["commit", "-m", "gitignore"], { cwd: repoRoot }).status, 0); + + const session = createWorktreeSession(repoRoot); + + try { + fs.mkdirSync(path.join(session.worktreePath, "dist"), { recursive: true }); + fs.writeFileSync(path.join(session.worktreePath, "dist", "artifact.txt"), "precious\n"); + + const result = cleanupWorktreeSession(session, { keep: true }); + + assert.equal(result.applied, false); + assert.match(result.detail, /ignored files/); + // The worktree and its ignored artifact MUST be preserved. + assert.equal(fs.existsSync(session.worktreePath), true); + assert.equal( + fs.readFileSync(path.join(session.worktreePath, "dist", "artifact.txt"), "utf8"), + "precious\n" + ); + } finally { + cleanupSession(session); + } +});