/* Unit tests for rfc 0568 clone reconcile covering its documented public contract and regression-sensitive edge cases. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-0568: initial integration tests for investigateUntrackedFiles and merge-based reconcile flow. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared */ import { test, expect, beforeEach, afterEach } from "vitest"; import { execSync } from "node:child_process"; import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import { investigateUntrackedFiles, isWorkpieceDirty } from "./mission-git-commit.ts"; function git(cwd: string, args: string): string { return execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); } async function setupRegistry(workspaceRoot: string, systemId: string): Promise { const cacheDir = path.join(workspaceRoot, "..", "systems-cache", systemId); await fs.mkdir(cacheDir, { recursive: true }); const configContent = `schemaVersion: system-config/v1\nid: ${systemId}\ncosmicStar: Vega\nmirrors:\n - path: "../systems-cache/${systemId}"\n storageType: non-bare\npinnedPlatform: "4.5.0"\nstatus: active\nregisteredAt: "2026-01-01T00:00:00Z"\nnotes: ""\n`; await fs.writeFile(path.join(cacheDir, "system-config.yaml"), configContent, "utf8"); } let tmpDir: string; let cacheCloneDir: string; let workpieceDir: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-0568-test-")); cacheCloneDir = path.join(tmpDir, "cache-clone"); workpieceDir = path.join(tmpDir, "workpiece"); // Create cache clone with initial content await fs.mkdir(cacheCloneDir, { recursive: true }); git(cacheCloneDir, "init"); git(cacheCloneDir, 'config user.email "test@example.com"'); git(cacheCloneDir, 'config user.name "Test"'); // Create data paths await fs.mkdir(path.join(cacheCloneDir, "src/content"), { recursive: true }); await fs.writeFile(path.join(cacheCloneDir, "src/content/system.md"), "# System\n"); await fs.mkdir(path.join(cacheCloneDir, "public"), { recursive: true }); await fs.writeFile(path.join(cacheCloneDir, "public/logo.svg"), ""); await fs.mkdir(path.join(cacheCloneDir, "provenance"), { recursive: true }); await fs.writeFile(path.join(cacheCloneDir, "provenance/pin.json"), "{}"); await fs.writeFile(path.join(cacheCloneDir, "system.pin.json"), '{"version":"1.0"}'); // Create bordbuch directory (non-data-path, should be removed from workpiece) await fs.mkdir(path.join(cacheCloneDir, "bordbuch"), { recursive: true }); await fs.writeFile(path.join(cacheCloneDir, "bordbuch/events.ndjson"), ""); git(cacheCloneDir, "add -A"); git(cacheCloneDir, 'commit -m "initial cache clone"'); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); test("investigateUntrackedFiles classifies boilerplate files as previous-mission when within mission time range", async () => { const workspaceRoot = path.join(tmpDir, "workspace"); await setupRegistry(workspaceRoot, "test-system"); // Create a bordbuch file with a mission-open entry const bordbuchDir = path.join(workspaceRoot, "..", "systems-cache", "test-system", "bordbuch"); await fs.mkdir(bordbuchDir, { recursive: true }); const missionOpenTime = new Date(Date.now() - 60000).toISOString(); // 1 minute ago const bordbuchEntry = { schemaVersion: "1.0.0", id: "event-000001", systemId: "test-system", occurredAt: missionOpenTime, kind: "mission-open", status: "done", missionId: "test-mission", releaseId: null, actor: "test-agent", summary: "Mission opened", previousHash: null, hash: "sha256:fake", }; await fs.writeFile(path.join(bordbuchDir, "events.ndjson"), JSON.stringify(bordbuchEntry) + "\n"); // Create an untracked boilerplate file in the cache clone const boilerplateFile = "package.json"; await fs.writeFile(path.join(cacheCloneDir, boilerplateFile), '{"name":"test"}'); const reports = await investigateUntrackedFiles(workspaceRoot, "test-system", cacheCloneDir, [ boilerplateFile, ]); expect(reports).toHaveLength(1); expect(reports[0].path).toBe(boilerplateFile); expect(reports[0].likelyOrigin).toBe("previous-mission"); }); test("investigateUntrackedFiles classifies non-boilerplate files as direct-commit when outside mission time range", async () => { // No bordbuch entries — no mission time ranges const workspaceRoot = path.join(tmpDir, "workspace"); await setupRegistry(workspaceRoot, "test-system"); await fs.mkdir(path.join(workspaceRoot, "..", "systems-cache", "test-system", "bordbuch"), { recursive: true, }); await fs.writeFile( path.join(workspaceRoot, "..", "systems-cache", "test-system", "bordbuch", "events.ndjson"), "", ); // Create a non-boilerplate untracked file const nonBoilerplateFile = "custom-config.yaml"; await fs.writeFile(path.join(cacheCloneDir, nonBoilerplateFile), "key: value"); const reports = await investigateUntrackedFiles(workspaceRoot, "test-system", cacheCloneDir, [ nonBoilerplateFile, ]); expect(reports).toHaveLength(1); expect(reports[0].path).toBe(nonBoilerplateFile); expect(reports[0].likelyOrigin).toBe("direct-commit"); }); test("investigateUntrackedFiles returns unknown for boilerplate files outside mission time range", async () => { const workspaceRoot = path.join(tmpDir, "workspace"); await setupRegistry(workspaceRoot, "test-system"); await fs.mkdir(path.join(workspaceRoot, "..", "systems-cache", "test-system", "bordbuch"), { recursive: true, }); await fs.writeFile( path.join(workspaceRoot, "..", "systems-cache", "test-system", "bordbuch", "events.ndjson"), "", ); // Create a boilerplate file but with no mission-open bordbuch entries const boilerplateFile = "package.json"; await fs.writeFile(path.join(cacheCloneDir, boilerplateFile), '{"name":"test"}'); const reports = await investigateUntrackedFiles(workspaceRoot, "test-system", cacheCloneDir, [ boilerplateFile, ]); expect(reports).toHaveLength(1); expect(reports[0].path).toBe(boilerplateFile); // isBoilerplate=true but inMissionRange=false → unknown expect(reports[0].likelyOrigin).toBe("unknown"); }); test("isWorkpieceDirty detects untracked files in cache clone", async () => { // Add an untracked file to the cache clone await fs.writeFile(path.join(cacheCloneDir, "untracked.txt"), "untracked"); const result = await isWorkpieceDirty(cacheCloneDir); expect(result.dirty).toBe(true); expect(result.fileCount).toBe(1); expect(result.files).toContain("untracked.txt"); }); test("git clone creates shared object database between cache clone and workpiece", async () => { // Simulate the clone-based materialization flow — git clone creates the dir git(tmpDir, `clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`); // Verify the workpiece has the same commits const cacheLog = git(cacheCloneDir, "log --oneline"); const workpieceLog = git(workpieceDir, "log --oneline"); expect(cacheLog).toBe(workpieceLog); // Verify data paths are present expect(existsSync(path.join(workpieceDir, "src/content/system.md"))).toBe(true); expect(existsSync(path.join(workpieceDir, "public/logo.svg"))).toBe(true); expect(existsSync(path.join(workpieceDir, "provenance/pin.json"))).toBe(true); expect(existsSync(path.join(workpieceDir, "system.pin.json"))).toBe(true); // Verify non-data-path files are also present (they'll be removed in materialize) expect(existsSync(path.join(workpieceDir, "bordbuch/events.ndjson"))).toBe(true); }); test("git merge --no-ff transfers workpiece commits to cache clone", async () => { // Clone cache clone into workpiece — git clone creates the dir git(tmpDir, `clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`); git(workpieceDir, 'config user.email "test@example.com"'); git(workpieceDir, 'config user.name "Test"'); // Make an edit in the workpiece (data path only) await fs.writeFile(path.join(workpieceDir, "src/content/system.md"), "# Updated System\n"); git(workpieceDir, "add src/content/system.md"); git(workpieceDir, 'commit -m "edit system.md"'); const preReconcileSha = git(cacheCloneDir, "rev-parse HEAD"); // Fetch and merge --no-ff (simulating reconcile) const workpieceBranch = git(workpieceDir, "rev-parse --abbrev-ref HEAD"); git(cacheCloneDir, `fetch ${JSON.stringify(workpieceDir)} ${JSON.stringify(workpieceBranch)}`); git(cacheCloneDir, `merge --no-ff FETCH_HEAD -m "reconcile mission test"`); // Verify the merge commit exists const postReconcileSha = git(cacheCloneDir, "rev-parse HEAD"); expect(postReconcileSha).not.toBe(preReconcileSha); // Verify the data change was transferred const content = execSync( `cat ${JSON.stringify(path.join(cacheCloneDir, "src/content/system.md"))}`, { encoding: "utf-8", }, ); expect(content).toContain("# Updated System"); // Verify transferred commit count const count = git(cacheCloneDir, `rev-list --count ${preReconcileSha}..FETCH_HEAD`); expect(parseInt(count, 10)).toBe(1); }); test("git merge --no-ff is idempotent via preReconcileSha reset", async () => { // Clone cache clone into workpiece — git clone creates the dir git(tmpDir, `clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`); git(workpieceDir, 'config user.email "test@example.com"'); git(workpieceDir, 'config user.name "Test"'); // Make an edit in the workpiece await fs.writeFile(path.join(workpieceDir, "src/content/system.md"), "# Updated System\n"); git(workpieceDir, "add src/content/system.md"); git(workpieceDir, 'commit -m "edit system.md"'); const preReconcileSha = git(cacheCloneDir, "rev-parse HEAD"); // First reconcile const workpieceBranch = git(workpieceDir, "rev-parse --abbrev-ref HEAD"); git(cacheCloneDir, `fetch ${JSON.stringify(workpieceDir)} ${JSON.stringify(workpieceBranch)}`); git(cacheCloneDir, `merge --no-ff FETCH_HEAD -m "reconcile mission test"`); const _firstPostSha = git(cacheCloneDir, "rev-parse HEAD"); // Simulate idempotent re-run: reset to preReconcileSha and re-merge git(cacheCloneDir, `reset --hard ${preReconcileSha}`); git(cacheCloneDir, `fetch ${JSON.stringify(workpieceDir)} ${JSON.stringify(workpieceBranch)}`); git(cacheCloneDir, `merge --no-ff FETCH_HEAD -m "reconcile mission test"`); const secondPostSha = git(cacheCloneDir, "rev-parse HEAD"); // The content should be the same after re-run expect(secondPostSha).not.toBe(preReconcileSha); // The SHAs may differ (merge commit timestamps differ) but content is the same const content = execSync( `cat ${JSON.stringify(path.join(cacheCloneDir, "src/content/system.md"))}`, { encoding: "utf-8", }, ); expect(content).toContain("# Updated System"); }); test("non-data-path files from clone are removed before boilerplate generation", async () => { // Clone cache clone into workpiece — git clone creates the dir git(tmpDir, `clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`); // Simulate removal of non-data-path files (as materialize does) const STERNSYSTEM_DATA_PATHS = ["src/content", "public", "provenance"]; const keepPaths = new Set([...STERNSYSTEM_DATA_PATHS, "system.pin.json", ".git"]); const entries = await fs.readdir(workpieceDir, { withFileTypes: true }); for (const entry of entries) { const isKeepPath = keepPaths.has(entry.name) || [...keepPaths].some((kp) => kp.startsWith(`${entry.name}/`)); if (!isKeepPath) { await fs.rm(path.join(workpieceDir, entry.name), { recursive: true, force: true }); } } // Verify data paths are preserved expect(existsSync(path.join(workpieceDir, "src/content/system.md"))).toBe(true); expect(existsSync(path.join(workpieceDir, "public/logo.svg"))).toBe(true); expect(existsSync(path.join(workpieceDir, "provenance/pin.json"))).toBe(true); expect(existsSync(path.join(workpieceDir, "system.pin.json"))).toBe(true); // Verify non-data-path files are removed expect(existsSync(path.join(workpieceDir, "bordbuch"))).toBe(false); // Verify .git is preserved expect(existsSync(path.join(workpieceDir, ".git"))).toBe(true); }); test("data-only materialize commit does not include boilerplate files", async () => { // Clone cache clone into workpiece — git clone creates the dir git(tmpDir, `clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`); git(workpieceDir, 'config user.email "test@example.com"'); git(workpieceDir, 'config user.name "Test"'); // Remove non-data-path files (as materialize does) const STERNSYSTEM_DATA_PATHS = ["src/content", "public", "provenance"]; const keepPaths = new Set([...STERNSYSTEM_DATA_PATHS, "system.pin.json", ".git"]); const entries = await fs.readdir(workpieceDir, { withFileTypes: true }); for (const entry of entries) { const isKeepPath = keepPaths.has(entry.name) || [...keepPaths].some((kp) => kp.startsWith(`${entry.name}/`)); if (!isKeepPath) { await fs.rm(path.join(workpieceDir, entry.name), { recursive: true, force: true }); } } // Add a boilerplate file (simulating generateFullBoilerplate) await fs.writeFile(path.join(workpieceDir, "package.json"), '{"name":"test"}'); await fs.writeFile(path.join(workpieceDir, "astro.config.mjs"), "export default {}"); // Stage only data paths (as materialize does) // After clone, data files are already tracked. We need to check if there are // any changes to stage. Since we removed non-data-path files, those removals // need to be staged too. But the materialize commit should only contain data paths. // The key assertion is that boilerplate files remain untracked. const dataPathsToAdd = [...STERNSYSTEM_DATA_PATHS, "system.pin.json"]; for (const dataPath of dataPathsToAdd) { const fullPath = path.join(workpieceDir, dataPath); if (existsSync(fullPath)) { git(workpieceDir, `add -- ${JSON.stringify(dataPath)}`); } } // Check what's staged — boilerplate files should NOT be staged const stagedFiles = git(workpieceDir, "diff --cached --name-only"); expect(stagedFiles).not.toContain("package.json"); expect(stagedFiles).not.toContain("astro.config.mjs"); // Data files are already tracked from clone — no changes to stage unless modified // The staged files list may be empty if no data files were modified // Commit (may be empty if no changes — that's fine, the point is boilerplate is untracked) try { git(workpieceDir, 'commit -m "materialize from pin 1.0"'); } catch { // No changes to commit — expected if data files weren't modified } // Verify boilerplate files are untracked const status = git(workpieceDir, "status --porcelain"); expect(status).toContain("?? package.json"); expect(status).toContain("?? astro.config.mjs"); });