/* Test RFC-0954: rescueWorkpieceEdits preserves uncommitted and unpushed workpiece edits before re-materialization overwrites the workpiece directory. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-0954: test rescue of dirty workpiece, new commits, no-op cases, merge failure backup, and bordbuch evidence fallback. */ import { test, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { execSync } from "node:child_process"; import path from "node:path"; import os from "node:os"; // Mock bordbuch-commit-helper to avoid real bordbuch I/O in tests vi.mock("../bordbuch/bordbuch-commit-helper.ts", () => ({ appendAndCommitBordbuch: vi.fn().mockResolvedValue({ entry: { id: "event-000001", kind: "operator-note" } as any, commitResult: { commitSha: "abc123", pushed: true, error: null }, }), })); import { rescueWorkpieceEdits } from "./workpiece-rescue.ts"; import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts"; const mockAppendBordbuch = vi.mocked(appendAndCommitBordbuch); const silentLogger = { info: () => {}, warn: () => {}, }; function gitInit(dir: string, branch = "main"): void { execSync(`git init -b ${branch}`, { cwd: dir, stdio: "pipe" }); execSync('git config user.email "test@test.local"', { cwd: dir, stdio: "pipe" }); execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); } function gitCommit(dir: string, msg: string): string { execSync(`git commit --allow-empty -m ${JSON.stringify(msg)}`, { cwd: dir, stdio: "pipe", encoding: "utf-8", }); return execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf-8", stdio: "pipe" }).trim(); } let testRoot: string; let cacheCloneDir: string; let workpieceDir: string; beforeEach(() => { testRoot = mkdtempSync(path.join(os.tmpdir(), "workpiece-rescue-test-")); cacheCloneDir = path.join(testRoot, "cache-clone"); workpieceDir = path.join(testRoot, "workpiece"); mkdirSync(cacheCloneDir, { recursive: true }); mkdirSync(workpieceDir, { recursive: true }); // Git identity for commits in cloned repos — `git clone` does not inherit the // source repo's local user.email/user.name, and CI runners have no global // git identity. Env vars cover every git command uniformly. vi.stubEnv("GIT_AUTHOR_NAME", "Test"); vi.stubEnv("GIT_AUTHOR_EMAIL", "test@test.local"); vi.stubEnv("GIT_COMMITTER_NAME", "Test"); vi.stubEnv("GIT_COMMITTER_EMAIL", "test@test.local"); mockAppendBordbuch.mockClear(); mockAppendBordbuch.mockResolvedValue({ entry: { id: "event-000001", kind: "operator-note" } as any, commitResult: { commitSha: "abc123", pushed: true, error: null }, }); }); afterEach(() => { vi.unstubAllEnvs(); rmSync(testRoot, { recursive: true, force: true }); }); test("no-op when workpiece does not exist", async () => { const result = await rescueWorkpieceEdits( "/nonexistent/workpiece", cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(false); expect(result.reason).toContain("workpiece not found"); }); test("no-op when workpiece is not a git repo", async () => { const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(false); expect(result.reason).toContain("not a git repo"); }); test("no-op when workpiece is closed (.closed sentinel)", async () => { gitInit(workpieceDir); gitCommit(workpieceDir, "initial"); writeFileSync(path.join(workpieceDir, ".closed"), ""); gitInit(cacheCloneDir); gitCommit(cacheCloneDir, "initial"); const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(false); expect(result.reason).toContain("closed"); }); test("no-op when cache clone is not a git repo", async () => { gitInit(workpieceDir); gitCommit(workpieceDir, "initial"); const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(false); expect(result.reason).toContain("cache clone is not a git repo"); }); test("rescues uncommitted changes — commits and merges into cache clone", async () => { gitInit(cacheCloneDir); const initialSha = gitCommit(cacheCloneDir, "initial"); execSync(`git clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`, { stdio: "pipe", }); writeFileSync(path.join(workpieceDir, "content.md"), "new content"); const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(true); expect(result.committedChanges).toBe(true); expect(result.mergedToCacheClone).toBe(true); expect(result.commitSha).not.toBe(null); expect(result.commitSha).not.toBe(initialSha); const cacheContent = execSync("git show HEAD:content.md", { cwd: cacheCloneDir, encoding: "utf-8", stdio: "pipe", }).trim(); expect(cacheContent).toBe("new content"); }); test("rescues new commits in workpiece — merges into cache clone", async () => { gitInit(cacheCloneDir); const initialSha = gitCommit(cacheCloneDir, "initial"); execSync(`git clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`, { stdio: "pipe", }); writeFileSync(path.join(workpieceDir, "page.md"), "page content"); execSync("git add -A", { cwd: workpieceDir, stdio: "pipe" }); execSync('git commit -m "add page"', { cwd: workpieceDir, stdio: "pipe" }); const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(true); expect(result.committedChanges).toBe(false); expect(result.mergedToCacheClone).toBe(true); expect(result.commitSha).not.toBe(initialSha); const cacheContent = execSync("git show HEAD:page.md", { cwd: cacheCloneDir, encoding: "utf-8", stdio: "pipe", }).trim(); expect(cacheContent).toBe("page content"); }); test("no-op when workpiece HEAD is already ancestor of cache clone HEAD", async () => { gitInit(cacheCloneDir); gitCommit(cacheCloneDir, "initial"); execSync(`git clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`, { stdio: "pipe", }); const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", silentLogger, ); expect(result.rescued).toBe(false); expect(result.committedChanges).toBe(false); expect(result.reason).toContain("no new commits"); }); test("backs up old workpiece on merge failure", async () => { gitInit(cacheCloneDir); gitCommit(cacheCloneDir, "initial"); writeFileSync(path.join(cacheCloneDir, "conflict.md"), "cache version"); execSync("git add -A", { cwd: cacheCloneDir, stdio: "pipe" }); execSync('git commit -m "cache adds conflict.md"', { cwd: cacheCloneDir, stdio: "pipe" }); execSync(`git clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`, { stdio: "pipe", }); execSync("git reset --hard HEAD~1", { cwd: workpieceDir, stdio: "pipe" }); writeFileSync(path.join(workpieceDir, "conflict.md"), "workpiece version"); execSync("git add -A", { cwd: workpieceDir, stdio: "pipe" }); execSync('git commit -m "workpiece adds conflict.md"', { cwd: workpieceDir, stdio: "pipe" }); const logs: string[] = []; const result = await rescueWorkpieceEdits(workpieceDir, cacheCloneDir, "test-m000001", { info: (msg: string) => logs.push(msg), warn: (msg: string) => logs.push(msg), }); expect(result.rescued).toBe(false); expect(result.mergedToCacheClone).toBe(false); expect(result.backupDir).not.toBe(null); expect(existsSync(result.backupDir!)).toBe(true); expect(logs.some((l) => l.includes("backed up"))).toBe(true); }); test("pushes rescued commits to bare repo when origin is configured", async () => { const bareRepoDir = path.join(testRoot, "bare.git"); execSync(`git init --bare ${JSON.stringify(bareRepoDir)}`, { stdio: "pipe" }); gitInit(cacheCloneDir); execSync(`git remote add origin ${JSON.stringify(bareRepoDir)}`, { cwd: cacheCloneDir, stdio: "pipe", }); gitCommit(cacheCloneDir, "initial"); execSync("git push -u origin main", { cwd: cacheCloneDir, stdio: "pipe" }); execSync(`git clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`, { stdio: "pipe", }); writeFileSync(path.join(workpieceDir, "content.md"), "rescued content"); const logs: string[] = []; const result = await rescueWorkpieceEdits(workpieceDir, cacheCloneDir, "test-m000001", { info: (msg: string) => logs.push(msg), warn: (msg: string) => logs.push(msg), }); expect(result.rescued).toBe(true); expect(result.mergedToCacheClone).toBe(true); expect(result.pushedToBareRepo).toBe(true); expect(logs.some((l) => l.includes("Pushed rescued commits"))).toBe(true); const bareContent = execSync("git show refs/heads/main:content.md", { cwd: bareRepoDir, encoding: "utf-8", stdio: "pipe", }).trim(); expect(bareContent).toBe("rescued content"); }); test("records bordbuch evidence when push to bare repo fails", async () => { // Cache clone with a broken origin (push will fail) gitInit(cacheCloneDir); gitCommit(cacheCloneDir, "initial"); execSync("git remote add origin /nonexistent/bare.git", { cwd: cacheCloneDir, stdio: "pipe", }); execSync(`git clone ${JSON.stringify(cacheCloneDir)} ${JSON.stringify(workpieceDir)}`, { stdio: "pipe", }); writeFileSync(path.join(workpieceDir, "content.md"), "rescued content"); const logs: string[] = []; const result = await rescueWorkpieceEdits( workpieceDir, cacheCloneDir, "test-m000001", { info: (msg: string) => logs.push(msg), warn: (msg: string) => logs.push(msg), }, testRoot, "test-system", ); expect(result.rescued).toBe(true); expect(result.mergedToCacheClone).toBe(true); expect(result.pushedToBareRepo).toBe(false); expect(result.bordbuchEvidence).toBe(true); expect(logs.some((l) => l.includes("Failed to push"))).toBe(true); expect(logs.some((l) => l.includes("bordbuch as evidence"))).toBe(true); expect(mockAppendBordbuch).toHaveBeenCalledTimes(1); const callArgs = mockAppendBordbuch.mock.calls[0]; expect(callArgs[2]).toBe("operator-note"); expect(callArgs[4]).toBe("mission:rescue"); });