fix(coding-agent): stopped isolated task merges failing when working tree carries WIP for files the agent also modifies
captureRepoDeltaPatch records the delta against `HEAD + WIP`, so the patch's context lines and blob SHAs reference the WIP-modified files. commitPatchToBranchWorktree then tried to apply that patch to a fresh worktree pinned at HEAD, which failed hard whenever the WIP-side file was missing from HEAD's index (untracked WIP files, staged-new WIP files) or when --3way could not resolve an overlap. commitPatchToBranchWorktree now tries plain apply first, then `--3way` (which cleanly subtracts WIP via the shared ODB blob for tracked files), and only when both fail replays baseline WIP into the temp worktree so the delta's context lines up, rewinding WIP-only files afterward so they never leak into the branch commit. Added git.ls.tree helper for the WIP-only-file filter and a set of regression tests covering the untracked, staged-new, and overlap scenarios. Fixes #4136
This commit is contained in:
@@ -641,4 +641,132 @@ describe("commitToBranch preserves agent commits", () => {
|
||||
const result = await commitToBranch(isolation, baseline, "empty", undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// Regression: #4136. Baseline WIP made captureRepoDeltaPatch record hunks
|
||||
// against `HEAD + WIP`; commitPatchToBranchWorktree then failed to apply
|
||||
// those hunks to a fresh worktree pinned at HEAD when the WIP-side file was
|
||||
// missing from HEAD's index (untracked WIP files, staged-new WIP files) or
|
||||
// when --3way couldn't resolve the overlap. Each scenario below reproduced
|
||||
// the failure before the fix.
|
||||
describe("with baseline WIP overlapping the agent's changes (#4136)", () => {
|
||||
async function seedWipFileFromParent(destRoot: string, relPath: string): Promise<void> {
|
||||
await fs.mkdir(path.join(destRoot, path.dirname(relPath)), { recursive: true });
|
||||
await fs.copyFile(path.join(parent, relPath), path.join(destRoot, relPath));
|
||||
}
|
||||
|
||||
it("commits an agent-only delta via --3way when WIP and agent modify unrelated hunks of the same tracked file", async () => {
|
||||
const fixture = "src/foo.py";
|
||||
const head = Array.from({ length: 40 }, (_, i) => `# line ${i + 1}\n`).join("");
|
||||
await fs.mkdir(path.join(parent, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(parent, fixture), head);
|
||||
await gitr(parent, ["add", "."]);
|
||||
await gitr(parent, ["commit", "-q", "-m", "add fixture"]);
|
||||
|
||||
// Isolation must be re-cloned so the fixture is present in HEAD.
|
||||
await fs.rm(isolation, { recursive: true, force: true });
|
||||
await gitr(parent, ["clone", "-q", "--no-hardlinks", "--local", parent, isolation]);
|
||||
await gitr(isolation, ["config", "user.email", "agent@example.com"]);
|
||||
await gitr(isolation, ["config", "user.name", "Agent User"]);
|
||||
|
||||
// Parent WIP: change line 10 (unstaged edit to an existing tracked file).
|
||||
const wipLines = head.split("\n");
|
||||
wipLines[9] = "# line 10 thinkingLevel: medium";
|
||||
await fs.writeFile(path.join(parent, fixture), wipLines.join("\n"));
|
||||
await seedWipFileFromParent(isolation, fixture);
|
||||
|
||||
// Agent modifies line 30, far from the WIP change.
|
||||
const agentLines = wipLines.slice();
|
||||
agentLines[29] = "# line 30 def new_func()";
|
||||
await fs.writeFile(path.join(isolation, fixture), agentLines.join("\n"));
|
||||
|
||||
const baseline = await captureBaseline(parent);
|
||||
const result = await commitToBranch(isolation, baseline, "wip-tracked-file", undefined);
|
||||
expect(result?.branchName).toBe("omp/task/wip-tracked-file");
|
||||
|
||||
const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]);
|
||||
expect(branchDiff).toContain("+# line 30 def new_func()");
|
||||
// --3way must subtract the WIP change from the commit; only the
|
||||
// agent's line 30 edit belongs on the task branch.
|
||||
expect(branchDiff).not.toContain("thinkingLevel: medium");
|
||||
});
|
||||
|
||||
it("commits an untracked WIP file that the agent modifies inside isolation", async () => {
|
||||
// Parent WIP: add a new untracked file the agent also touches.
|
||||
await fs.mkdir(path.join(parent, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(parent, "src/new.py"), "WIP header\nunchanged\n");
|
||||
await fs.mkdir(path.join(isolation, "src"), { recursive: true });
|
||||
await fs.copyFile(path.join(parent, "src/new.py"), path.join(isolation, "src/new.py"));
|
||||
|
||||
// Agent extends the file inside isolation.
|
||||
await fs.writeFile(path.join(isolation, "src/new.py"), "WIP header\nagent-edit\n");
|
||||
|
||||
const baseline = await captureBaseline(parent);
|
||||
expect(baseline.root.untracked).toContain("src/new.py");
|
||||
const result = await commitToBranch(isolation, baseline, "wip-untracked", undefined);
|
||||
expect(result?.branchName).toBe("omp/task/wip-untracked");
|
||||
|
||||
const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]);
|
||||
expect(branchDiff).toContain("new file mode");
|
||||
expect(branchDiff).toContain("src/new.py");
|
||||
expect(branchDiff).toContain("+WIP header");
|
||||
expect(branchDiff).toContain("+agent-edit");
|
||||
});
|
||||
|
||||
it("commits a staged-new WIP file that the agent modifies inside isolation", async () => {
|
||||
// Parent WIP: stage a new file that isn't yet in HEAD.
|
||||
await fs.writeFile(path.join(parent, "notes.md"), "l1\nl2\nl3\n");
|
||||
await gitr(parent, ["add", "notes.md"]);
|
||||
await fs.copyFile(path.join(parent, "notes.md"), path.join(isolation, "notes.md"));
|
||||
await gitr(isolation, ["add", "notes.md"]);
|
||||
|
||||
// Agent edits the staged-new file.
|
||||
await fs.writeFile(path.join(isolation, "notes.md"), "l1\nl2 agent\nl3\n");
|
||||
|
||||
const baseline = await captureBaseline(parent);
|
||||
expect(baseline.root.staged).toContain("new file mode");
|
||||
const result = await commitToBranch(isolation, baseline, "wip-staged-new", undefined);
|
||||
expect(result?.branchName).toBe("omp/task/wip-staged-new");
|
||||
|
||||
const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]);
|
||||
expect(branchDiff).toContain("new file mode");
|
||||
expect(branchDiff).toContain("notes.md");
|
||||
expect(branchDiff).toContain("+l2 agent");
|
||||
});
|
||||
|
||||
it("does not leak WIP-only files into the branch commit when the agent leaves them untouched", async () => {
|
||||
// Parent WIP: touch two files. The agent only edits `wanted.py`;
|
||||
// `wip-only.py` must not appear in the branch commit at all.
|
||||
await fs.mkdir(path.join(parent, "src"), { recursive: true });
|
||||
await fs.writeFile(path.join(parent, "src/wanted.py"), "unchanged\n");
|
||||
await fs.writeFile(path.join(parent, "src/wip-only.py"), "unchanged\n");
|
||||
await gitr(parent, ["add", "."]);
|
||||
await gitr(parent, ["commit", "-q", "-m", "seed"]);
|
||||
await fs.rm(isolation, { recursive: true, force: true });
|
||||
await gitr(parent, ["clone", "-q", "--no-hardlinks", "--local", parent, isolation]);
|
||||
await gitr(isolation, ["config", "user.email", "agent@example.com"]);
|
||||
await gitr(isolation, ["config", "user.name", "Agent User"]);
|
||||
|
||||
await fs.writeFile(path.join(parent, "src/wip-only.py"), "wip edit\n");
|
||||
await fs.writeFile(path.join(parent, "src/wanted.py"), "wip mixed\n");
|
||||
await fs.copyFile(path.join(parent, "src/wip-only.py"), path.join(isolation, "src/wip-only.py"));
|
||||
await fs.copyFile(path.join(parent, "src/wanted.py"), path.join(isolation, "src/wanted.py"));
|
||||
// Untracked WIP file the agent also does not touch.
|
||||
await fs.writeFile(path.join(parent, "user-wip.txt"), "user wip\n");
|
||||
await fs.copyFile(path.join(parent, "user-wip.txt"), path.join(isolation, "user-wip.txt"));
|
||||
|
||||
// Agent only extends `wanted.py`; leaves `wip-only.py` and
|
||||
// `user-wip.txt` alone.
|
||||
await fs.writeFile(path.join(isolation, "src/wanted.py"), "wip mixed\nagent line\n");
|
||||
|
||||
const baseline = await captureBaseline(parent);
|
||||
const result = await commitToBranch(isolation, baseline, "wip-filter", undefined);
|
||||
expect(result?.branchName).toBe("omp/task/wip-filter");
|
||||
|
||||
const files = (await gitr(parent, ["show", "--name-only", "--pretty=format:", result!.branchName!]))
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
// Only the agent-touched file lands on the branch — no WIP-only files.
|
||||
expect(files).toEqual(["src/wanted.py"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user