import { execFile } from "node:child_process"; import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { GitWorkspaceRepository, WorkspaceRepositoryLock, normalizeRepositoryIdentity, } from "../src/workspaces/git-repository.js"; import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: schema_version: 2 id: psd-clinical name: Policlinico San Donato language: it dwh: engine: postgres database: postgres schema: datawarehouse supported_transports: [postgres_direct] semantic_index: vector_store: engine: pgvector database: postgres schema: vectors collection: clinical_documents dimensions: 768 distance: cosine supported_transports: [pgvector_direct] embedding: provider: ollama_compatible model: nomic-embed-text-v2-moe dimensions: 768 llm_policy: allowed: [zai/glm-5.2] `; const runFile = promisify(execFile); const temporaryRoots: string[] = []; afterEach(() => { temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); }); async function git(cwd: string, args: string[]): Promise { await runFile("git", args, { cwd }); } async function temporaryRemote(): Promise<{ root: string; remote: string; source: string; initialCommit: string }> { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-")); temporaryRoots.push(root); const remote = join(root, "remote.git"); const source = join(root, "source"); await git(root, ["init", "--bare", "--initial-branch=main", remote]); mkdirSync(source); await git(source, ["init", "--initial-branch=main"]); await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: psd-clinical, name: Policlinico San Donato}, {id: research, name: Policlinico San Donato}]\n"); mkdirSync(join(source, "psd-clinical"), { recursive: true }); mkdirSync(join(source, "research"), { recursive: true }); writeFileSync(join(source, "psd-clinical", "workspace.yaml"), validYaml); writeFileSync(join(source, "research", "workspace.yaml"), validYaml .replace("id: psd-clinical", "id: research")); mkdirSync(join(source, "research", "evidence"), { recursive: true }); mkdirSync(join(source, "other", "evidence"), { recursive: true }); mkdirSync(join(source, "blob"), { recursive: true }); mkdirSync(join(source, "link"), { recursive: true }); writeFileSync(join(source, "research", "evidence", "guide.md"), "guide v1\n"); writeFileSync(join(source, "other", "evidence", "other.md"), "other\n"); writeFileSync(join(source, "blob", "evidence"), "not a tree\n"); symlinkSync("../research/evidence", join(source, "link", "evidence")); symlinkSync("guide.md", join(source, "research", "evidence", "nested-link")); await git(source, ["add", "-A"]); await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: source }); return { root, remote, source, initialCommit: stdout.trim() }; } function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { return { root, remoteUrl, branch: "main", gitAuthorName: "Workspace Registry Test", gitAuthorEmail: "workspace-registry@example.invalid", installationId: "test", secretRoots: [], maxImportBytes: 1024, maxImportEntries: 1, }; } test("does not expose repository mutation or publication operations", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); expect(repository).not.toHaveProperty("createRegistryFile"); expect(repository).not.toHaveProperty("writeRegistryFile"); expect(repository).not.toHaveProperty("removeRegistryFile"); expect(repository).not.toHaveProperty("commitAndPush"); }); test.each([ ["https://github.com/aritmolab/workspaces.git", { host: "github.com", repository: "aritmolab/workspaces", transport: "https", }], ["ssh://git@gitlab.example.org/clinical/workspaces.git", { host: "gitlab.example.org", repository: "clinical/workspaces", transport: "ssh", }], ["git@gitea.example.org:clinical/workspaces.git", { host: "gitea.example.org", repository: "clinical/workspaces", transport: "ssh", }], ] as const)("normalizes the safe repository identity for %s", (remote, expected) => { expect(normalizeRepositoryIdentity(remote)).toEqual(expected); }); test.each([ "https://user:secret@github.com/aritmolab/workspaces.git", "https://github.com/aritmolab/workspaces.git?token=secret", "ssh://git:secret@gitlab.example.org/clinical/workspaces.git", ])("rejects a remote that could expose embedded credentials: %s", (remote) => { expect(() => normalizeRepositoryIdentity(remote)).toThrow("Workspace Git remote is invalid"); }); test("bootstraps a persistent checkout from a local bare repository", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); const status = await repository.bootstrap(); expect(status).toMatchObject({ branch: "main", head: fixture.initialCommit, ahead: 0, behind: 0, degraded: false, }); }); test("accepts only a tree at the declared Evidence root for the requested revision", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); await expect(repository.assertTreeAtRevision( fixture.initialCommit, "research/evidence", )).resolves.toBeUndefined(); await expect(repository.assertTreeAtRevision( fixture.initialCommit, "missing/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, "blob/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, "link/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); }); test("redacts Git failures while checking an Evidence tree", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); rmSync(repository.repoPath, { recursive: true, force: true }); const error = await repository.assertTreeAtRevision( fixture.initialCommit, "research/evidence", ).catch((failure: unknown) => failure); expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" }); expect((error as Error).message).not.toContain(fixture.root); }); test("classifies repository corruption as unavailable rather than invalid Evidence", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); rmSync(join(repository.repoPath, ".git", "objects"), { recursive: true, force: true }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, "research/evidence", )).rejects.toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" }); }); test("binds Evidence tree validation to old and new content-only commits", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); writeFileSync(join(fixture.source, "research", "evidence", "guide.md"), "guide v2\n"); await git(fixture.source, ["add", "research/evidence/guide.md"]); await git(fixture.source, ["commit", "-m", "Update Evidence content"]); await git(fixture.source, ["push", "origin", "main"]); const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: fixture.source }); const newCommit = stdout.trim(); await repository.pull(); const oldTree = (await runFile("git", ["rev-parse", `${fixture.initialCommit}:research/evidence`], { cwd: fixture.source, })).stdout.trim(); const newTree = (await runFile("git", ["rev-parse", `${newCommit}:research/evidence`], { cwd: fixture.source, })).stdout.trim(); expect(newTree).not.toBe(oldTree); await expect(repository.assertTreeAtRevision( fixture.initialCommit, "research/evidence", )).resolves.toBeUndefined(); await expect(repository.assertTreeAtRevision( newCommit, "research/evidence", )).resolves.toBeUndefined(); }); test("defers nested Evidence symlink containment to P6", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); // Task 2 validates only the declared root object. Recursive containment remains a P6 boundary. await expect(repository.assertTreeAtRevision( fixture.initialCommit, "research/evidence", )).resolves.toBeUndefined(); }); test("rejects malformed revisions and shell-like paths without executing them", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); const marker = join(fixture.root, "shell-marker"); await expect(repository.assertTreeAtRevision( "HEAD", "research/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, `research/evidence;touch ${marker}`, )).rejects.toMatchObject({ code: "workspace_invalid" }); expect(existsSync(marker)).toBe(false); }); test("redacts failed Git checkout details behind a stable error code", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-missing-")); temporaryRoots.push(root); const remote = join(root, "missing.git"); const repository = new GitWorkspaceRepository(config(join(root, "registry"), remote)); const error = await repository.bootstrap().catch((error: unknown) => error); expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed", }); expect((error as Error).message).not.toContain(remote); }); test("maps registry-layout failures to a stable redacted error", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-layout-")); temporaryRoots.push(root); const file = join(root, "not-a-directory"); writeFileSync(file, "occupied"); const repository = new GitWorkspaceRepository(config(file, join(root, "remote.git"))); const error = await repository.bootstrap().catch((error: unknown) => error); expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace registry storage is unavailable", }); expect((error as Error).message).not.toContain(file); }); test("maps lock filesystem failures to a stable redacted error", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-lock-")); temporaryRoots.push(root); const file = join(root, "not-a-directory"); writeFileSync(file, "occupied"); const lock = new WorkspaceRepositoryLock(file); const error = await lock.run(async () => undefined).catch((error: unknown) => error); expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace registry lock is unavailable", }); expect((error as Error).message).not.toContain(file); }); test("releases its queue after a malformed lock failure so a later attempt can acquire", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-queue-")); temporaryRoots.push(root); const locks = join(root, "locks"); mkdirSync(join(locks, "repository.lock"), { recursive: true }); const lock = new WorkspaceRepositoryLock(locks); await expect(lock.run(async () => "unreachable")).rejects.toMatchObject({ code: "git_unavailable" }); rmSync(join(locks, "repository.lock"), { recursive: true, force: true }); const result = await Promise.race([ lock.run(async () => "recovered"), new Promise((resolve) => setTimeout(() => resolve("timed out"), 2_000)), ]); expect(result).toBe("recovered"); }); test("parallel contenders recover a stale lock file without overlapping critical sections", async () => { const root = mkdtempSync(join(tmpdir(), "thoth-workspace-git-contenders-")); temporaryRoots.push(root); const locks = join(root, "locks"); mkdirSync(locks); writeFileSync(join(locks, "repository.lock"), JSON.stringify({ pid: 999_999_999 })); const first = new WorkspaceRepositoryLock(locks); const second = new WorkspaceRepositoryLock(locks); let active = 0; let maximum = 0; const critical = async () => { active += 1; maximum = Math.max(maximum, active); await new Promise((resolve) => setTimeout(resolve, 25)); active -= 1; }; const results = await Promise.allSettled([first.run(critical), second.run(critical)]); expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); expect(maximum).toBe(1); });