Files
PaperClipAI/server/src/__tests__/persisted-workspace-source.test.ts
T
Devin FoleyandPaperclip 5717523b9e fix: retain the original repository for reused task workspaces (#15528)
Apply the reviewed change for fix: retain the original repository for reused task workspaces.

Validation: required local typecheck and tests, passing CI, Greptile 5/5, and independent review of the exact source head.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 19:52:30 -07:00

297 lines
18 KiB
TypeScript

import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolvePersistedGitWorkspaceSource } from "../services/persisted-workspace-source.js";
const exec = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
});
async function fixture() {
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "persisted-source-")));
roots.push(root);
const original = path.join(root, "original");
const replacement = path.join(root, "replacement");
const worktree = path.join(root, "task");
for (const repo of [original, replacement]) {
await exec("git", ["init", repo]);
await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]);
await exec("git", ["-C", repo, "remote", "add", "origin", `https://example.invalid/${path.basename(repo)}.git`]);
}
await exec("git", ["-C", original, "worktree", "add", "-b", "task", worktree]);
await fs.writeFile(path.join(worktree, "retained.txt"), "keep this work\n");
const workspace = {
id: "execution-workspace", companyId: "company", projectId: "project", projectWorkspaceId: null,
repoUrl: "https://example.invalid/original.git", baseRef: "HEAD", cwd: worktree, providerRef: worktree,
};
return { root, original, replacement, worktree, workspace, input: {
companyId: "company", projectId: "project", explicitProjectWorkspaceId: null,
workspace, boundProjectWorkspace: null, candidateBaseCwds: [original], managedSourceRoot: root,
} };
}
describe("persisted Git workspace source", () => {
it("keeps the original registered source after its project binding is deleted", async () => {
const { original, replacement, worktree, input } = await fixture();
const source = await resolvePersistedGitWorkspaceSource(input);
expect(source).toBe(original);
expect(source).not.toBe(replacement);
expect(input.workspace.projectWorkspaceId).toBeNull();
expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
});
it.each(["companyId", "projectId"] as const)("rejects a foreign %s before source inspection or materialization", async field => {
const { input } = await fixture();
const materializeOriginalRepository = vi.fn();
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, [field]: "foreign" },
materializeOriginalRepository })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
});
it("rejects an explicit new project selection without rebinding the old task source", async () => {
const { input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input, explicitProjectWorkspaceId: "replacement" })).rejects.toMatchObject({
code: "workspace_validation_failed", resultJson: { workspaceValidation: { reasonCode: "explicit_project_workspace_conflict" } },
});
});
it.each([null, { id: "bound", companyId: "foreign", projectId: "project", cwd: null },
{ id: "bound", companyId: "company", projectId: "foreign", cwd: null }])("rejects an unverified non-null project binding (%j)", async binding => {
const { input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, projectWorkspaceId: "bound" },
boundProjectWorkspace: binding })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
});
it("rejects a supplied source binding when the saved association is null", async () => {
const { original, input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
boundProjectWorkspace: { id: "foreign-binding", companyId: "company", projectId: "project", cwd: original },
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
});
it("does not trust another Git owner just because its origin matches", async () => {
const { original, replacement, input } = await fixture();
await exec("git", ["-C", replacement, "remote", "set-url", "origin", input.workspace.repoUrl]);
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({
resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } },
});
expect(original).not.toBe(replacement);
});
it("uses the stored origin identity without transport-only URL rewrites", async () => {
const { original, input } = await fixture();
await exec("git", ["-C", original, "config", "url.ssh://git@example.invalid/.insteadOf", "https://example.invalid/"]);
expect((await exec("git", ["-C", original, "remote", "get-url", "origin"])).stdout.trim()).not.toBe(input.workspace.repoUrl);
expect(await resolvePersistedGitWorkspaceSource(input)).toBe(original);
});
it("rejects a symlink that redirects a managed source into another repository", async () => {
const { root, original, input } = await fixture();
const alias = path.join(root, "scoped", "original");
await fs.mkdir(path.dirname(alias));
await fs.symlink(original, alias);
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [alias] })).rejects.toMatchObject({
resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } },
});
});
it("rejects multiple origin URLs even if the last one matches the saved repository", async () => {
const { original, input } = await fixture();
await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]);
await exec("git", ["-C", original, "config", "--add", "remote.origin.url", input.workspace.repoUrl]);
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: {
reasonCode: "source_repository_mismatch",
} } });
});
it("rejects changed origin identity even at the recorded managed path", async () => {
const { original, input } = await fixture();
await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]);
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: {
reasonCode: "source_repository_mismatch",
} } });
});
it("refuses an independent clone masquerading as the selected linked worktree", async () => {
const { root, original, input } = await fixture();
const clone = path.join(root, "clone");
await exec("git", ["clone", original, clone]);
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone },
candidateBaseCwds: [clone] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("refuses a primary checkout whose separate Git directory impersonates a managed base", async () => {
const { root, original, input } = await fixture();
const fakeOwner = path.join(root, "fake-owner");
const clone = path.join(root, "separate-checkout");
await fs.mkdir(fakeOwner);
await exec("git", ["clone", "--separate-git-dir", path.join(fakeOwner, ".git"), original, clone]);
await exec("git", ["-C", clone, "remote", "set-url", "origin", input.workspace.repoUrl]);
// This shape can appear in a complete worktree list, but has no per-worktree
// storage under the owner's .git/worktrees directory.
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone },
candidateBaseCwds: [fakeOwner] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("preserves registered local sources whose configured cwd is a repository subfolder", async () => {
const { original, input } = await fixture();
const cwd = path.join(original, "subfolder");
await fs.mkdir(cwd);
expect(await resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd },
})).toBe(original);
});
it("supports a bound local repository with separate Git storage", async () => {
const { root, input } = await fixture();
const owner = path.join(root, "separate-owner");
const storage = path.join(root, "git-storage");
const task = path.join(root, "separate-task");
await exec("git", ["init", "--separate-git-dir", storage, owner]);
await exec("git", ["-C", owner, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]);
await exec("git", ["-C", owner, "worktree", "add", "-b", "retained", task]);
expect(await resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, cwd: task, providerRef: task, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: owner },
})).toBe(owner);
});
it("does not reconstruct a missing local worktree from its binding's replacement cwd", async () => {
const { original, replacement, worktree, input } = await fixture();
await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement },
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
});
it("fails closed when a legacy local source lost its only project association", async () => {
const { input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, repoUrl: null },
candidateBaseCwds: [] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("rejects a changed bound local cwd instead of adopting its new repository", async () => {
const { replacement, input } = await fixture();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement },
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
});
it("materializes only the recorded original source when both managed checkout and worktree are absent", async () => {
const { root, original, input } = await fixture();
const restored = path.join(root, "restored-source");
const missingWorktree = path.join(root, "missing-worktree");
const materializeOriginalRepository = vi.fn(async () => {
await exec("git", ["clone", original, restored]);
await exec("git", ["-C", restored, "remote", "set-url", "origin", input.workspace.repoUrl]);
return restored;
});
expect(await resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
candidateBaseCwds: [restored], materializeOriginalRepository,
})).toBe(restored);
expect(materializeOriginalRepository).toHaveBeenCalledTimes(1);
});
it("rejects a materializer result outside the retained source candidates", async () => {
const { root, original, input } = await fixture();
const missingWorktree = path.join(root, "missing-worktree");
const materializeOriginalRepository = vi.fn(async () => original);
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
candidateBaseCwds: [path.join(root, "expected-clone")], materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
expect(materializeOriginalRepository).toHaveBeenCalledTimes(1);
});
it("does not invoke materialization through a managed project symlink", async () => {
const { root, original, input } = await fixture();
const scoped = path.join(root, "scoped");
await fs.symlink(original, scoped);
const missingWorktree = path.join(root, "missing-worktree");
const materializeOriginalRepository = vi.fn(async () => original);
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
candidateBaseCwds: [path.join(scoped, "new-clone")], materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
});
it.each([false, true])("keeps bounded Git inspection evidence before restoring a retained source (missing=%s)", async missing => {
const { root, original, worktree, input } = await fixture();
if (missing) await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
const realGit = (await exec("/bin/sh", ["-c", "command -v git"])).stdout.trim();
const bin = path.join(root, "bin");
await fs.mkdir(bin);
await fs.writeFile(path.join(bin, "git"), `#!${process.execPath}\nconst args=process.argv.slice(2);\nif(args[0]==="worktree"&&args[1]==="list"){process.stderr.write("private-provider-output /private/path");process.exitCode=128;}else{const r=require("node:child_process").spawnSync(${JSON.stringify(realGit)},args,{stdio:"inherit"});process.exitCode=r.status??1;}\n`, { mode: 0o755 });
vi.stubEnv("PATH", `${bin}${path.delimiter}${process.env.PATH ?? ""}`);
const materializeOriginalRepository = vi.fn();
const failure = await resolvePersistedGitWorkspaceSource({ ...input, materializeOriginalRepository }).catch(error => error);
expect(failure).toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: {
reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed",
inspectionDiagnostic: { command: "worktree_list", failure: "nonzero_exit", exitCode: 128 },
} } });
expect(JSON.stringify(failure.resultJson)).not.toContain("private");
expect(materializeOriginalRepository).not.toHaveBeenCalled();
if (!missing) expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
});
it("rebuilds a missing worktree only from the retained source, not a new primary", async () => {
const { original, replacement, worktree, input } = await fixture();
await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
expect(await resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement, original] })).toBe(original);
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({
resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } },
});
});
it("accepts a symlink for the configured instance root only", async () => {
const { root, original, input } = await fixture();
const alias = `${root}-alias`;
roots.push(alias);
await fs.symlink(root, alias);
expect(await resolvePersistedGitWorkspaceSource({ ...input, managedSourceRoot: alias,
candidateBaseCwds: [path.join(alias, "original")] })).toBe(original);
});
it("mismatched saved paths never materialize another source", async () => {
const { replacement, input } = await fixture();
const materializeOriginalRepository = vi.fn();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, providerRef: replacement }, materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
});
it("a dangling saved-worktree symlink is not replaced", async () => {
const { root, input } = await fixture();
const dangling = path.join(root, "dangling-task");
await fs.symlink(path.join(root, "absent-target"), dangling);
const materializeOriginalRepository = vi.fn();
await expect(resolvePersistedGitWorkspaceSource({ ...input,
workspace: { ...input.workspace, cwd: dangling, providerRef: dangling }, materializeOriginalRepository,
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } });
expect(materializeOriginalRepository).not.toHaveBeenCalled();
expect((await fs.lstat(dangling)).isSymbolicLink()).toBe(true);
});
it("a substituted worktree backlink cannot authorize reuse", async () => {
const { original, worktree, input } = await fixture();
const storage = (await exec("git", ["-C", worktree, "rev-parse", "--absolute-git-dir"])).stdout.trim();
await fs.writeFile(path.join(storage, "gitdir"), path.join(original, ".git") + "\n");
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: {
workspaceValidation: { reasonCode: "source_registration_unproven" },
} });
expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
});
});