fix: prepare nested repositories in declared plain task roots

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-21 07:43:24 -05:00
1 parent ab067f59b0
commit a7511226e8
5 files changed
+70 -10

No files matched your search

@@ -96,3 +96,18 @@ E2E support checks pass 38. Server, runner, and E2E typechecks pass. Copilot v54
binds the changed shared completion guidance; v53 remains a historical identity.
The three development pilot failures remain retained. These checks do not count
as the required staging matrix, and a fresh provider run remains necessary.
The fourth development attempt reached a successor through a monitor fallback,
then failed before provider startup while applying Git exclusions to the task
root. It is a failure of the required same-agent journey, even though a successor
started. The log records Git's exact non-repository error. A real-Git and database
reproduction shows the same error for a declared plain task directory containing
inert Git metadata. The original attempt did not retain that metadata's contents
or establish which component created it.
The next narrow repair lets only an explicitly declared plain task root proceed
past Git's exact non-repository result. It leaves the existing files intact.
Configured checkouts keep strict validation, real Git roots still receive the
exclusion, linked metadata stays rejected, and missing Git stays an error. The
repair's Git and authorization cohort passes locally. Fresh provider
remeasurement and the monitor-fallback defect remain open.
@@ -1,14 +1,14 @@
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../services/managed-repository-checkout.js";
const execFile = promisify(execFileCallback);
const directories: string[] = [];
afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); });
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); });
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-publication-"));
directories.push(root);
@@ -33,6 +33,34 @@ describe("atomic managed repository publication", () => {
await ensureManagedRepositoriesIgnored(source);
expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/");
});
it("allows a declared plain task directory with inert Git metadata without changing its files", async () => {
const { target } = await fixture();
await mkdir(path.join(target, ".git", "info"), { recursive: true });
const file = path.join(target, ".git", "info", "exclude");
await writeFile(file, "/.paperclip-repositories/\n");
await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).resolves.toBeUndefined();
expect(await readFile(file, "utf8")).toBe("/.paperclip-repositories/\n");
await expect(ensureManagedRepositoriesIgnored(target)).rejects.toThrow("not a git repository");
});
it("still excludes managed repositories when a task directory contains a real checkout", async () => {
const { source } = await fixture();
await ensureManagedRepositoriesIgnored(source, { plainTaskDirectory: true });
expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/");
});
it("retains unexpected Git errors for declared plain task directories", async () => {
const { root, target } = await fixture();
await mkdir(path.join(target, ".git"), { recursive: true });
vi.stubEnv("PATH", path.join(root, "missing-git"));
await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects linked Git metadata even for a declared plain task directory", async () => {
const { source, target } = await fixture();
await mkdir(target);
await symlink(path.join(source, ".git"), path.join(target, ".git"), "dir");
const original = await readFile(path.join(source, ".git", "info", "exclude"), "utf8");
await expect(ensureManagedRepositoriesIgnored(target, { plainTaskDirectory: true })).rejects.toThrow("must not be a symlink");
expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toBe(original);
});
it("writes the pinned receipt inside the clone before making it visible", async () => {
const { source, target } = await fixture();
const result = await materializeManagedProjectWorkspace(target, { repoUrl: "https://github.com/test/source", localSource: source, beforePublish: async clone => {
@@ -33,7 +33,7 @@ const support = await getEmbeddedPostgresTestSupport();
let db: ReturnType<typeof createDb>;
let root: string;
beforeAll(async () => { ({ db, root } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); });
afterEach(() => { acquisition.materialize.mockReset(); vi.clearAllMocks(); vi.restoreAllMocks(); });
afterAll(context.cleanup);
const { fixture, lowTrustPeer, standardPeer, privateTask, localPrivateClone } = context;
@@ -334,7 +334,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it.each(["sponsored", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => {
it.each(["sponsored", "sponsored_git_residue", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => {
const f = await fixture();
await db.update(executionWorkspaces).set({ status: "idle" }).where(eq(executionWorkspaces.id, f.workspace.id));
const localBoard = principal === "local_board" || principal === "local_board_jwt";
@@ -367,6 +367,10 @@ const support = await getEmbeddedPostgresTestSupport();
await exec("git", ["-C", cwd, "remote", "set-url", "origin", input.repoUrl]);
return result;
});
if (principal === "sponsored_git_residue") {
await mkdir(path.join(f.admission.cwd, ".git", "info"), { recursive: true });
await writeFile(path.join(f.admission.cwd, ".git", "info", "exclude"), "/.paperclip-repositories/\n");
}
const [prepared] = await f.service.prepareForAdmission(admission);
expect(prepared.id).toBe(receipt.operationId);
expect(await readFile(path.join(prepared.cwd, "public.txt"), "utf8")).toBe("Public fixture bytes\n");
@@ -318,7 +318,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
return candidatePin;
}
async function prepareForAdmission(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId: string | null }) {
const { acquisitionRestricted } = await publicationBinding(db, input, false);
const { acquisitionRestricted, workspace } = await publicationBinding(db, input, false);
const rows = await list(input.companyId, input.workspaceId);
if (!rows.length) return [];
await publicationBinding(db, input);
@@ -361,7 +361,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
await fs.writeFile(ignoreTemporary, "*\n", { flag: "wx", mode: 0o600 });
await fs.rename(ignoreTemporary, ignorePath);
} finally { await fs.rm(ignoreTemporary, { force: true }); }
await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root));
await measureRunPhase("workspace.repository.ignore_rules", () => ensureManagedRepositoriesIgnored(root, {
plainTaskDirectory: workspace.strategyType === "task_directory" && !workspace.repoUrl && !workspace.projectWorkspaceId,
}));
}
return stagingParent;
});
@@ -162,12 +162,23 @@ export async function materializeManagedProjectWorkspace(
}
export async function ensureManagedRepositoriesIgnored(cwd: string): Promise<void> {
export async function ensureManagedRepositoriesIgnored(cwd: string, options: { plainTaskDirectory?: boolean } = {}): Promise<void> {
const gitEntry = await fs.lstat(path.join(cwd, ".git")).catch(() => null);
if (!gitEntry) return;
if (gitEntry.isSymbolicLink()) throw new Error("Workspace Git metadata must not be a symlink");
const excludePath = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 })
.then((result) => path.resolve(cwd, result.stdout.trim()));
let excludePath: string;
try {
const result = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], {
timeout: 10_000, env: { ...process.env, LC_ALL: "C" },
});
excludePath = path.resolve(cwd, result.stdout.trim());
} catch (error) {
const failure = error as { code?: unknown; signal?: unknown; stderr?: unknown };
if (options.plainTaskDirectory && failure.code === 128 && failure.signal == null &&
failure.stderr === "fatal: not a git repository (or any of the parent directories): .git\n" &&
!process.env.GIT_DIR && !process.env.GIT_WORK_TREE && !process.env.GIT_COMMON_DIR) return;
throw error;
}
const exclude = await fs.readFile(excludePath, "utf8").catch(() => "");
if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) {
await fs.mkdir(path.dirname(excludePath), { recursive: true });