diff --git a/server/src/__tests__/git-workspace-base-storage.test.ts b/server/src/__tests__/git-workspace-base-storage.test.ts new file mode 100644 index 00000000000..509ce5a97c7 --- /dev/null +++ b/server/src/__tests__/git-workspace-base-storage.test.ts @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { assertReproducibleBaseGitStorage } from "../services/git-workspace-restoration.js"; +import { ensureManagedRepositoriesIgnored } from "../services/managed-repository-checkout.js"; + +describe("reconstructable base Git metadata", () => { + let root: string; + let checkout: string; + let exclude: string; + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-base-storage-")); + const source = path.join(root, "source"); + checkout = path.join(root, "checkout"); + await fs.mkdir(source); + const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "-c", "commit.gpgsign=false", ...args], { stdio: "pipe", env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull } }); + git(source, "init", "--initial-branch=main"); + await fs.writeFile(path.join(source, "marker.txt"), "fixture\n"); + git(source, "add", "."); git(source, "commit", "-m", "Fixture"); + git(root, "clone", source, checkout); + exclude = path.join(checkout, ".git", "info", "exclude"); + await ensureManagedRepositoriesIgnored(checkout); + }); + afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); }); + + it("accepts the ignore entry produced by normal repository preparation", async () => { + await expect(assertReproducibleBaseGitStorage(checkout)).resolves.toBeUndefined(); + }); + + it.each([ + ["custom pattern", Buffer.from("private-output/\n")], + ["custom comment", Buffer.from("# Retain my local metadata\n")], + ["nonstandard bytes", Buffer.from([0xff, 0x00])], + ["duplicate generated entry", Buffer.from("\n/.paperclip-repositories/\n")], + ])("preserves a %s beyond the generated ignore entry", async (_name, bytes) => { + await fs.appendFile(exclude, bytes); + const original = await fs.readFile(exclude); + await expect(assertReproducibleBaseGitStorage(checkout)).rejects.toThrow("custom_git_metadata"); + expect(await fs.readFile(exclude)).toEqual(original); + }); + + it("preserves a symlink even when its target contains the generated ignore entry", async () => { + const target = path.join(root, "original-exclude"); + await fs.rename(exclude, target); + await fs.symlink(target, exclude); + const original = await fs.readFile(target); + await expect(assertReproducibleBaseGitStorage(checkout)).rejects.toThrow("custom_git_metadata"); + expect((await fs.lstat(exclude)).isSymbolicLink()).toBe(true); + expect(await fs.readFile(target)).toEqual(original); + }); +}); diff --git a/server/src/__tests__/git-workspace-expiration.test.ts b/server/src/__tests__/git-workspace-expiration.test.ts index 083abeae18c..e528ee7b614 100644 --- a/server/src/__tests__/git-workspace-expiration.test.ts +++ b/server/src/__tests__/git-workspace-expiration.test.ts @@ -47,7 +47,7 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { await db.$client.end({ timeout: 1 }); await database.cleanup(); await fs.rm(root, { recursive: true, force: true }); }); - async function fixture(managed = false, nested = false) { + async function fixture(managed = false, nested = false, withProject = managed) { const companyId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID(); const location = path.join(root, companyId), source = path.join(location, "author"), remote = path.join(location, "remote.git"); await fs.mkdir(source, { recursive: true }); @@ -60,7 +60,7 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { await db.insert(companies).values({ id: companyId, name: "Git expiration fixture", issuePrefix: `GC${companyId.slice(0, 6)}` }); await db.insert(issues).values({ id: issueId, companyId, title: "Finished task", status: "done", completedAt: new Date(Date.now() - 48 * 3_600_000) }); const repoUrl = pathToFileURL(remote).href; - const projectId = managed ? randomUUID() : null; + const projectId = withProject ? randomUUID() : null; if (projectId) await db.insert(projects).values({ id: projectId, companyId, name: "Fixture project" }); const baseCwd = managed ? (await materializeManagedProjectWorkspace(resolveManagedProjectWorkspaceDir({ companyId, projectId: projectId!, repoName: "remote" }), { repoUrl })).cwd @@ -149,8 +149,8 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { expect(await fs.readFile(settingsPath, "utf8")).toBe("later task settings\n"); }); - async function nestedFixture() { - const f = await fixture(true); + async function nestedFixture(managed = true) { + const f = await fixture(managed, !managed, true); const nestedSource = path.join(path.dirname(f.source), "nested-author"); const nestedRemote = path.join(path.dirname(f.source), "nested.git"); await fs.mkdir(nestedSource); @@ -174,6 +174,40 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { return { ...f, nestedSource, nestedRemote, nestedA, preparation, nested: nested!, settings, settingsPath, nestedSettingsPath }; } + it("expires a nested workspace and its unused private base with the generated ignore entry", async () => { + const f = await nestedFixture(false); + await f.advance(); + await fs.writeFile(path.join(f.nestedSource, "nested.txt"), "nested B\n"); + git(f.nestedSource, "add", "."); git(f.nestedSource, "commit", "-m", "Nested B"); + git(f.nestedSource, "push", f.nestedRemote, "main"); + const result = await f.expire(); + expect(result).toMatchObject({ expired: true, baseExpired: true, skipReason: null }); + expect(result.reclaimedBytes).toBeGreaterThan(0); + expect(await exists(f.cwd)).toBe(false); + expect(await exists(f.baseCwd)).toBe(false); + expect(await f.reopen()).toMatchObject({ ok: true, reopened: true }); + expect(git(f.cwd, "rev-parse", "HEAD")).toBe(f.commitA); + expect(git(f.nested.cwd, "rev-parse", "HEAD")).toBe(f.nestedA); + expect(await fs.readFile(f.settingsPath)).toEqual(f.settings); + expect((await fs.stat(f.settingsPath)).mode & 0o777).toBe(0o600); + expect(await fs.readFile(f.nestedSettingsPath)).toEqual(f.settings); + expect((await fs.stat(f.nestedSettingsPath)).mode & 0o777).toBe(0o640); + await fs.writeFile(path.join(f.nested.cwd, "continued.txt"), "continued after base restoration\n"); + git(f.nested.cwd, "add", "."); git(f.nested.cwd, "commit", "-m", "Continued after base restoration"); + expect(git(f.nested.cwd, "rev-parse", "HEAD")).not.toBe(f.nestedA); + }); + + it("retains custom base ignore patterns after expiring the nested workspace", async () => { + const f = await nestedFixture(false); + const exclude = path.join(f.baseCwd, ".git", "info", "exclude"); + await fs.appendFile(exclude, "private-output/\n"); + const original = await fs.readFile(exclude); + expect(await f.expire()).toMatchObject({ expired: true, baseExpired: false, skipReason: "custom_git_metadata" }); + expect(await exists(f.cwd)).toBe(false); + expect(await exists(f.baseCwd)).toBe(true); + expect(await fs.readFile(exclude)).toEqual(original); + }); + async function crashWithExpirationLocks(f: Awaited>, beforePublication = false) { expect(await f.expire({ beforeDelete: async () => { throw new Error("stop_before_locks"); } })) .toMatchObject({ expired: false, skipReason: "stop_before_locks" }); diff --git a/server/src/services/git-workspace-restoration.ts b/server/src/services/git-workspace-restoration.ts index fa18ed9d73e..c05a14bbfc1 100644 --- a/server/src/services/git-workspace-restoration.ts +++ b/server/src/services/git-workspace-restoration.ts @@ -156,7 +156,12 @@ export async function assertReproducibleBaseGitStorage(cwd: string, signal?: Abo for (const name of await fs.readdir(path.join(root, directory))) { const file = `${directory}/${name}`; const entry = await fs.lstat(path.join(root, file)); - if (!entry.isFile() || !templates.get(file)?.equals(await fs.readFile(path.join(root, file)))) throw new Error("custom_git_metadata"); + if (!entry.isFile()) throw new Error("custom_git_metadata"); + const actual = await fs.readFile(path.join(root, file)); + const template = templates.get(file); + const managedExclude = file === "info/exclude" && template + ? Buffer.concat([template, Buffer.from(`\n/${PROJECT_REPOSITORIES_DIR}/\n`)]) : null; + if (!template?.equals(actual) && !managedExclude?.equals(actual)) throw new Error("custom_git_metadata"); } } if (!templates.get("description")?.equals(await fs.readFile(path.join(root, "description")))) throw new Error("custom_git_metadata");