fix(task): preserved nested-repo dirty state across nested patch apply

applyNestedPatches() applied the captured patch then ran git.stage.files(nestedDir), which stages every working-tree change in the nested repo. A nested repo that was already dirty before the agent ran ended up with the user's unrelated work-in-progress committed alongside the agent delta.

Stash any pre-existing dirty state (tracked + untracked) before applying the patch and pop it back in the finally block after the commit, so the agent commit contains only the captured patch and the user's in-flight work is restored on top of it. A failing stash pop logs a warning and leaves the stash entry intact for manual recovery; the broader nested-apply failure path is already non-fatal.

Added a worktree integration test that confirms a pre-existing untracked file in the nested repo is not staged into the agent commit and is still present in the working tree afterwards.

Fixes #3196
This commit is contained in:
roboomp
2026-06-22 19:34:42 +00:00
parent 28137f46d9
commit 978d2a76d0
2 changed files with 90 additions and 8 deletions
+34 -8
View File
@@ -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),
});
}
}
}
}
}
@@ -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");
});
});