mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
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>
297 lines
18 KiB
TypeScript
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");
|
|
});
|
|
|
|
});
|