Files
oh-my-pi/packages/coding-agent/test/autoresearch-git.test.ts
T
roboomp b6aafab3c1 fix(coding-agent): made isPureJjRepo depth-aware so nested git checkouts win
Previously `isPureJjRepo` returned true whenever the resolved jj and git roots merely differed. That punishes the legitimate inverse-nesting case: a real git checkout (vendored repo, fixture, independent nested checkout) living UNDER an outer pure jj workspace. Both `jj.repo.root` and `git.repo.root` walk upward from cwd and return the closest ancestor — so the deeper root is the one the user is actually working inside, and Git automation against the inner checkout never touches the surrounding jj tree.

`isPureJjRepo` now returns true iff jj is the *deeper* ancestor (or no git is present at all). The new `isStrictDescendant` helper does the depth check via `path.relative`. Added unit + integration tests covering both nesting directions on all three surfaces (`utils/jj`, `task/worktree`, `autoresearch/git`).

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

133 lines
5.3 KiB
TypeScript

import { afterEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { ensureAutoresearchBranch } from "../src/autoresearch/git";
import type { ExtensionAPI } from "../src/extensibility/extensions";
import * as jj from "../src/utils/jj";
const tempDirs: string[] = [];
async function mkTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function runGit(cwd: string, args: string[]): Promise<void> {
const env = { ...process.env, HOME: cwd, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" };
const proc = Bun.spawn(["git", "-C", cwd, ...args], { env, stdout: "ignore", stderr: "pipe" });
const code = await proc.exited;
if (code !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(`git ${args.join(" ")} failed (${code}): ${stderr}`);
}
}
async function initGitWithCommit(dir: string): Promise<void> {
await runGit(dir, ["init", "-q", "-b", "main"]);
await runGit(dir, ["config", "user.email", "test@example.com"]);
await runGit(dir, ["config", "user.name", "Test"]);
await fs.writeFile(path.join(dir, "README"), "seed\n");
await runGit(dir, ["add", "."]);
await runGit(dir, ["commit", "-q", "-m", "init"]);
}
// `ensureAutoresearchBranch` never invokes the `api` it receives — its two
// internal helpers (`readGitWorkDirPrefix`, `branchExists`) immediately
// `void api`. The stub keeps the signature honest without spinning up the
// real extension runtime.
const stubApi = {} as unknown as ExtensionAPI;
afterEach(async () => {
jj.repo.clearRootCache();
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
describe("ensureAutoresearchBranch jj guardrails", () => {
it("rejects pure jj workspaces before touching git state", async () => {
const dir = await mkTempDir("omp-ar-purejj-");
await fs.mkdir(path.join(dir, ".jj", "repo", "store"), { recursive: true });
const result = await ensureAutoresearchBranch(stubApi, dir, "demo");
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.error).toMatch(/pure Jujutsu/);
expect(result.error).toMatch(/jj git init --colocate/);
});
it("creates an autoresearch branch in a colocated jj-git workspace", async () => {
const dir = await mkTempDir("omp-ar-colocated-");
await initGitWithCommit(dir);
await fs.mkdir(path.join(dir, ".jj", "repo", "store"), { recursive: true });
const result = await ensureAutoresearchBranch(stubApi, dir, "demo goal");
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.created).toBe(true);
expect(result.branchName).toMatch(/^autoresearch\/demo-goal-\d{8}$/);
});
it("creates an autoresearch branch in a plain git repo (unchanged behavior)", async () => {
const dir = await mkTempDir("omp-ar-plaingit-");
await initGitWithCommit(dir);
const result = await ensureAutoresearchBranch(stubApi, dir, "demo");
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.created).toBe(true);
expect(result.branchName).toMatch(/^autoresearch\/demo-\d{8}$/);
});
it("returns the soft no-git warning for directories backed by neither tool", async () => {
const dir = await mkTempDir("omp-ar-empty-");
const result = await ensureAutoresearchBranch(stubApi, dir, "demo");
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.branchName).toBeNull();
expect(result.warning).toMatch(/Not in a git repository/);
});
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, autoresearch would create
// `autoresearch/*` branches and commits in the surrounding git tree
// behind jj's back.
const outer = await mkTempDir("omp-ar-nested-outer-");
await initGitWithCommit(outer);
const inner = path.join(outer, "nested-jj");
await fs.mkdir(path.join(inner, ".jj", "repo", "store"), { recursive: true });
const result = await ensureAutoresearchBranch(stubApi, inner, "demo");
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
expect(result.error).toMatch(/pure Jujutsu/);
expect(result.error).toMatch(/jj git init --colocate/);
});
it("creates an autoresearch branch in a nested git checkout under an outer jj workspace", async () => {
// Mirror image of the case above: `jj.repo.root(inner)` finds the outer
// .jj, but `git.repo.root(inner)` finds the inner .git, so autoresearch
// safely targets the nested checkout and never touches the surrounding
// jj tree.
const outer = await mkTempDir("omp-ar-outerjj-");
await fs.mkdir(path.join(outer, ".jj", "repo", "store"), { recursive: true });
const inner = path.join(outer, "vendor");
await fs.mkdir(inner, { recursive: true });
await initGitWithCommit(inner);
const result = await ensureAutoresearchBranch(stubApi, inner, "demo");
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.created).toBe(true);
expect(result.branchName).toMatch(/^autoresearch\/demo-\d{8}$/);
});
});