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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user