Merge pull request #3205: feat(eval): isolated/apply/merge options for agent() helper

Resolves conflict in test/task/worktree.test.ts by keeping both the
getRepoRoot (main) and applyNestedPatches (PR) describe blocks.

Extends the PR's Python/JS work to the remaining workflow runtimes:
- eval/rb/prelude.rb, eval/jl/prelude.jl: agent() now accepts and
  forwards isolated/apply/merge (as booleans) plus returnHandle, and the
  return_handle node carries isolated/patch_path/branch_name/
  nested_patches/changes_applied/isolation_summary.

Post-merge fixups:
- task/index.ts: drop dead commitStyle var (the dedup refactor reads
  task.isolation.commits inside makeIsolationCommitMessage).
- CHANGELOG: move the misplaced Added entry under [Unreleased], correct
  the stale "defaults track task.isolation.mode" wording to the final
  strict opt-in behavior, and note all four runtimes.

Fixes #3196
This commit is contained in:
can1357
2026-06-22 21:56:45 +02:00
15 changed files with 1511 additions and 292 deletions
@@ -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,
@@ -279,3 +280,88 @@ describe("getRepoRoot", () => {
expect(await getRepoRoot(inner)).toBe(inner);
});
});
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");
});
it("restores pre-existing staged WIP to the index, not just the working tree", async () => {
// Pre-existing tracked file with a staged edit; the patch should leave
// this entirely alone, and the stash pop must re-stage it (--index).
await fs.writeFile(path.join(nestedDir, "other.txt"), "tracked v1\n");
await runGit(nestedDir, ["add", "other.txt"]);
await runGit(nestedDir, ["commit", "-q", "-m", "add-other"]);
await fs.writeFile(path.join(nestedDir, "other.txt"), "staged wip\n");
await runGit(nestedDir, ["add", "other.txt"]);
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, statusPorcelain, cachedDiff] = await Promise.all([
runGit(nestedDir, ["log", "-1", "--name-only", "--pretty=format:"]),
runGit(nestedDir, ["status", "--porcelain=v1"]),
runGit(nestedDir, ["diff", "--cached", "--", "other.txt"]),
]);
expect(committedFiles.trim()).toBe("file.txt");
// Leading "M " (with trailing space) marks an index-only modification —
// "M" in the first slot, " " in the second. " M" would mean unstaged.
expect(statusPorcelain).toBe("M other.txt");
expect(cachedDiff).toContain("+staged wip");
});
});