diff --git a/packages/coding-agent/src/task/worktree.ts b/packages/coding-agent/src/task/worktree.ts index a10220e41..b9f828537 100644 --- a/packages/coding-agent/src/task/worktree.ts +++ b/packages/coding-agent/src/task/worktree.ts @@ -186,6 +186,14 @@ export async function captureDeltaPatch(isolationDir: string, baseline: Worktree /** * Apply nested repo patches directly to their working directories after parent merge. + * + * Pre-existing dirty state in a nested repo is stashed before the patch is + * applied and popped back after the commit, so unrelated user edits never get + * folded into the agent's commit. A failing `git stash pop` (e.g. user edits + * collide with the patched lines) leaves the stash entry intact and emits a + * `logger.warn` — the caller's catch handler turns the broader nested-apply + * failure into a non-fatal system notification. + * * @param commitMessage Optional async function to generate a commit message from the combined diff. * If omitted or returns null, falls back to a generic message. */ @@ -212,15 +220,33 @@ export async function applyNestedPatches( } const combinedDiff = repoPatches.map(p => p.patch).join("\n"); - for (const { patch } of repoPatches) { - await git.patch.applyText(nestedDir, patch); - } - // Commit so nested repo history reflects the task changes - if ((await git.status(nestedDir)).trim().length > 0) { - const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)"; - await git.stage.files(nestedDir); - await git.commit(nestedDir, msg); + // Preserve any pre-existing dirty state (tracked + untracked) so we + // commit only the agent delta, not the user's in-flight work. + const stashed = + (await git.status(nestedDir)).trim().length > 0 + ? await git.stash.push(nestedDir, `omp-isolation-${Snowflake.next()}`) + : false; + try { + for (const { patch } of repoPatches) { + await git.patch.applyText(nestedDir, patch); + } + if ((await git.status(nestedDir)).trim().length > 0) { + const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)"; + await git.stage.files(nestedDir); + await git.commit(nestedDir, msg); + } + } finally { + if (stashed) { + try { + await git.stash.pop(nestedDir); + } catch (popErr) { + logger.warn("Pre-existing nested-repo dirty state could not be auto-restored", { + nestedDir, + error: popErr instanceof Error ? popErr.message : String(popErr), + }); + } + } } } } diff --git a/packages/coding-agent/test/task/worktree.test.ts b/packages/coding-agent/test/task/worktree.test.ts index 958f20555..20744d5ff 100644 --- a/packages/coding-agent/test/task/worktree.test.ts +++ b/packages/coding-agent/test/task/worktree.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { + applyNestedPatches, captureBaseline, captureDeltaPatch, ensureIsolation, @@ -198,3 +199,58 @@ describe("worktree isolation helpers", () => { }); }); }); + +describe("applyNestedPatches", () => { + let parentRepo: string; + let nestedRel: string; + let nestedDir: string; + + beforeEach(async () => { + parentRepo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-nested-apply-")); + await runGit(parentRepo, ["init", "-q", "-b", "main"]); + await runGit(parentRepo, ["config", "user.email", "test@example.com"]); + await runGit(parentRepo, ["config", "user.name", "Test User"]); + await fs.writeFile(path.join(parentRepo, ".gitignore"), "sub/\n"); + await runGit(parentRepo, ["add", "."]); + await runGit(parentRepo, ["commit", "-q", "-m", "parent-init"]); + + nestedRel = "sub"; + nestedDir = path.join(parentRepo, nestedRel); + await fs.mkdir(nestedDir, { recursive: true }); + await runGit(nestedDir, ["init", "-q", "-b", "main"]); + await runGit(nestedDir, ["config", "user.email", "test@example.com"]); + await runGit(nestedDir, ["config", "user.name", "Test User"]); + await fs.writeFile(path.join(nestedDir, "file.txt"), "v1\n"); + await runGit(nestedDir, ["add", "."]); + await runGit(nestedDir, ["commit", "-q", "-m", "nested-init"]); + }); + + afterEach(async () => { + await fs.rm(parentRepo, { recursive: true, force: true }); + }); + + it("does not fold pre-existing dirty nested-repo state into the agent commit", async () => { + // User has unrelated work-in-progress in the nested repo before the agent runs. + await fs.writeFile(path.join(nestedDir, "other.txt"), "user wip\n"); + + const patch = + "diff --git a/file.txt b/file.txt\n" + + "--- a/file.txt\n" + + "+++ b/file.txt\n" + + "@@ -1 +1 @@\n" + + "-v1\n" + + "+v2\n"; + await applyNestedPatches(parentRepo, [{ relativePath: nestedRel, patch }]); + + const [committedFiles, headContent, otherContent, statusPorcelain] = await Promise.all([ + runGit(nestedDir, ["log", "-1", "--name-only", "--pretty=format:"]), + fs.readFile(path.join(nestedDir, "file.txt"), "utf8"), + fs.readFile(path.join(nestedDir, "other.txt"), "utf8"), + runGit(nestedDir, ["status", "--porcelain=v1"]), + ]); + expect(committedFiles.trim()).toBe("file.txt"); + expect(headContent).toBe("v2\n"); + expect(otherContent).toBe("user wip\n"); + expect(statusPorcelain).toBe("?? other.txt"); + }); +});