Files
oh-my-pi/packages/coding-agent/test/task/worktree.test.ts
T
roboomp d7c7d2b0e1 fix(coding-agent): guarded nested pure jj before outer-git fallback
Previously `getRepoRoot` and `ensureAutoresearchBranch` only consulted `jj.isPureJjRepo` after `git.repo.root` returned null. For a jj workspace nested under an unrelated outer Git checkout, `git.repo.root` walks up and finds the outer .git, so the pure-jj branch never fired and isolation/autoresearch silently mutated the surrounding Git tree behind jj's back.

Both call sites now run the pure-jj guard first, rejecting at the jj workspace's own root regardless of any surrounding Git checkout. Added integration tests for the nested case on both surfaces.

Refs #1935
2026-06-05 14:44:05 +00:00

202 lines
8.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as natives from "@oh-my-pi/pi-natives";
import {
captureBaseline,
captureDeltaPatch,
ensureIsolation,
getGitNoIndexNullPath,
getRepoRoot,
mergeTaskBranches,
parseIsolationMode,
} from "../../src/task/worktree";
import * as jj from "../../src/utils/jj";
const tempDirs: string[] = [];
async function runGit(repo: string, args: string[]): Promise<string> {
const proc = Bun.spawn(["git", ...args], {
cwd: repo,
stderr: "pipe",
stdout: "pipe",
windowsHide: true,
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if ((exitCode ?? 0) !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed with exit code ${exitCode ?? 0}`);
}
return stdout.trim();
}
async function createGitRepo(): Promise<{ baseBranch: string; repo: string }> {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-worktree-"));
tempDirs.push(repo);
await runGit(repo, ["init"]);
await runGit(repo, ["config", "user.email", "test@example.com"]);
await runGit(repo, ["config", "user.name", "Test User"]);
await fs.writeFile(path.join(repo, "merged.txt"), "base version\n");
await fs.writeFile(path.join(repo, "staged.txt"), "base staged\n");
await runGit(repo, ["add", "."]);
await runGit(repo, ["commit", "-m", "initial"]);
return {
baseBranch: await runGit(repo, ["branch", "--show-current"]),
repo,
};
}
afterEach(async () => {
vi.restoreAllMocks();
jj.repo.clearRootCache();
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
describe("worktree isolation helpers", () => {
it("returns platform-specific null path for git --no-index diffs", () => {
const expected = process.platform === "win32" ? "NUL" : "/dev/null";
expect(getGitNoIndexNullPath()).toBe(expected);
});
it("maps every isolation mode to the native backend contract", () => {
expect(parseIsolationMode("none")).toBeUndefined();
expect(parseIsolationMode("auto")).toBeUndefined();
expect(parseIsolationMode("apfs")).toBe(natives.IsoBackendKind.Apfs);
expect(parseIsolationMode("btrfs")).toBe(natives.IsoBackendKind.Btrfs);
expect(parseIsolationMode("zfs")).toBe(natives.IsoBackendKind.Zfs);
expect(parseIsolationMode("reflink")).toBe(natives.IsoBackendKind.LinuxReflink);
expect(parseIsolationMode("overlayfs")).toBe(natives.IsoBackendKind.Overlayfs);
expect(parseIsolationMode("fuse-overlay")).toBe(natives.IsoBackendKind.Overlayfs);
expect(parseIsolationMode("projfs")).toBe(natives.IsoBackendKind.Projfs);
expect(parseIsolationMode("fuse-projfs")).toBe(natives.IsoBackendKind.Projfs);
expect(parseIsolationMode("block-clone")).toBe(natives.IsoBackendKind.WindowsBlockClone);
expect(parseIsolationMode("rcopy")).toBe(natives.IsoBackendKind.Rcopy);
expect(parseIsolationMode("worktree")).toBe(natives.IsoBackendKind.Rcopy);
});
it("retries isoResolve candidates when a backend is path-unavailable", async () => {
const { repo } = await createGitRepo();
const unavailable = new Error("ISO_UNAVAILABLE: btrfs source is not a subvolume");
const isoResolve = vi.spyOn(natives, "isoResolve").mockReturnValue({
kind: natives.IsoBackendKind.Btrfs,
candidates: [natives.IsoBackendKind.Btrfs, natives.IsoBackendKind.Rcopy],
fellBack: false,
reason: undefined,
});
const isoStart = vi
.spyOn(natives, "isoStart")
.mockRejectedValueOnce(unavailable)
.mockResolvedValueOnce(undefined);
vi.spyOn(natives, "isoIsUnavailableError").mockImplementation(message => message.startsWith("ISO_UNAVAILABLE:"));
const handle = await ensureIsolation(repo, "retry-path-unavailable");
expect(isoResolve).toHaveBeenCalledWith(null);
expect(isoStart.mock.calls.map(call => call[0])).toEqual([
natives.IsoBackendKind.Btrfs,
natives.IsoBackendKind.Rcopy,
]);
expect(handle.backend).toBe(natives.IsoBackendKind.Rcopy);
expect(handle.fellBack).toBe(true);
expect(handle.fallbackReason).toBe(unavailable.message);
});
it("does not pop an unrelated pre-existing stash when the working tree is clean", async () => {
const { repo } = await createGitRepo();
await fs.writeFile(path.join(repo, "preexisting.txt"), "user stash\n");
await runGit(repo, ["stash", "push", "--include-untracked", "-m", "preexisting-user-stash"]);
const before = await runGit(repo, ["stash", "list"]);
const result = await mergeTaskBranches(repo, []);
expect(result).toEqual({ failed: [], merged: [] });
expect(await runGit(repo, ["stash", "list"])).toBe(before);
expect(await runGit(repo, ["status", "--porcelain=v1"])).toBe("");
});
it("restores staged changes with index preservation after merging task branches", async () => {
const { baseBranch, repo } = await createGitRepo();
const taskBranch = "task/merge-staged";
await runGit(repo, ["checkout", "-b", taskBranch]);
await fs.writeFile(path.join(repo, "merged.txt"), "task branch change\n");
await runGit(repo, ["add", "merged.txt"]);
await runGit(repo, ["commit", "-m", "task-change"]);
await runGit(repo, ["checkout", baseBranch]);
await fs.writeFile(path.join(repo, "staged.txt"), "local staged change\n");
await runGit(repo, ["add", "staged.txt"]);
expect(await runGit(repo, ["status", "--porcelain=v1"])).toBe("M staged.txt");
const result = await mergeTaskBranches(repo, [{ branchName: taskBranch, taskId: "task-1" }]);
expect(result).toEqual({ failed: [], merged: [taskBranch] });
expect(await fs.readFile(path.join(repo, "merged.txt"), "utf8")).toBe("task branch change\n");
expect(await runGit(repo, ["status", "--porcelain=v1"])).toBe("M staged.txt");
expect(await runGit(repo, ["diff", "--cached", "--", "staged.txt"])).toContain("+local staged change");
expect(await runGit(repo, ["stash", "list"])).toBe("");
});
it("subtracts baseline dirty state even when the task commits it", async () => {
const { repo } = await createGitRepo();
await fs.writeFile(path.join(repo, "merged.txt"), "baseline dirty change\n");
await fs.writeFile(path.join(repo, "preexisting.txt"), "baseline untracked\n");
const baseline = await captureBaseline(repo);
await runGit(repo, ["add", "-A"]);
await runGit(repo, ["commit", "-m", "baseline committed inside isolation"]);
await fs.writeFile(path.join(repo, "task.txt"), "task output\n");
await runGit(repo, ["add", "task.txt"]);
await runGit(repo, ["commit", "-m", "task output"]);
const delta = await captureDeltaPatch(repo, baseline);
expect(delta.nestedPatches).toEqual([]);
expect(delta.rootPatch).toContain("task.txt");
expect(delta.rootPatch).toContain("+task output");
expect(delta.rootPatch).not.toContain("baseline dirty change");
expect(delta.rootPatch).not.toContain("preexisting.txt");
});
});
describe("getRepoRoot", () => {
it("returns the git root for a plain git checkout", async () => {
const { repo } = await createGitRepo();
expect(await getRepoRoot(repo)).toBe(repo);
});
it("returns the git root for a colocated jj-git workspace", async () => {
const { repo } = await createGitRepo();
await fs.mkdir(path.join(repo, ".jj", "repo", "store"), { recursive: true });
expect(await getRepoRoot(repo)).toBe(repo);
});
it("rejects pure jj workspaces with an actionable Jujutsu message", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-purejj-"));
tempDirs.push(dir);
await fs.mkdir(path.join(dir, ".jj", "repo", "store"), { recursive: true });
await expect(getRepoRoot(dir)).rejects.toThrow(/pure Jujutsu/);
await expect(getRepoRoot(dir)).rejects.toThrow(/jj git init --colocate/);
});
it("preserves the generic git-not-found error for directories without any repo", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-norepo-"));
tempDirs.push(dir);
await expect(getRepoRoot(dir)).rejects.toThrow("Git repository not found for isolated task execution.");
});
it("rejects a pure jj workspace nested inside an unrelated outer git checkout", async () => {
// `git.repo.root(inner)` walks up and finds the outer .git — without
// the pure-jj check running first, isolation would silently target the
// surrounding git tree behind jj's back.
const { repo: outer } = await createGitRepo();
const inner = path.join(outer, "nested-jj");
await fs.mkdir(path.join(inner, ".jj", "repo", "store"), { recursive: true });
await expect(getRepoRoot(inner)).rejects.toThrow(/pure Jujutsu/);
await expect(getRepoRoot(inner)).rejects.toThrow(/jj git init --colocate/);
});
});