/* RFC-0954: rescueWorkpieceEdits — preserves uncommitted and unpushed workpiece edits before re-materialization overwrites the workpiece directory. Do not mutate the workpiece — rescue only recovers state, it never rewrites content. RFC-0954: initial rescueWorkpieceEdits implementation. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1159: step 4 — exec.exitcode.lint command, sweep remainder, packages-check wiring */ import path from "node:path"; import { createDefaultIO, execOrThrow, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts"; import type { BordbuchEntryKind } from "@warpgogol/werkstatt-engine/schemas"; export interface WorkpieceRescueResult { rescued: boolean; committedChanges: boolean; commitSha: string | null; mergedToCacheClone: boolean; pushedToBareRepo: boolean; bordbuchEvidence: boolean; backupDir: string | null; reason: string; } function noop(reason: string): WorkpieceRescueResult { return { rescued: false, committedChanges: false, commitSha: null, mergedToCacheClone: false, pushedToBareRepo: false, bordbuchEvidence: false, backupDir: null, reason, }; } async function git(cwd: string, args: string, io: WorkspaceIO): Promise { // Shell-string args preserved verbatim via sh -c — interpolated JSON.stringify // quoting keeps identical semantics without re-tokenizing. const result = await io.exec("sh", ["-c", `git ${args}`], { cwd, env: { MISSION_GIT_COMMIT: "1" }, }); if (result.exitCode !== 0) { throw new Error(`git failed (exit ${result.exitCode}): ${result.stderr.trim()}`); } return result.stdout.trim(); } export async function rescueWorkpieceEdits( workpieceDir: string, cacheCloneDir: string, missionId: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }, workspaceRoot?: string, systemId?: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(workpieceDir)) || !(await io.exists(path.join(workpieceDir, ".git")))) { return noop("workpiece not found or not a git repo"); } if (await io.exists(path.join(workpieceDir, ".closed"))) { return noop("workpiece is closed"); } if (!(await io.exists(path.join(cacheCloneDir, ".git")))) { return noop("cache clone is not a git repo"); } let committedChanges = false; // Step 1: Commit uncommitted changes in workpiece try { const status = await git(workpieceDir, "status --porcelain", io); if (status) { await git(workpieceDir, "add -A", io); await git( workpieceDir, `commit --no-verify -m "rescue: auto-commit uncommitted workpiece edits before re-materialization ${missionId}"`, io, ); committedChanges = true; logger.info(` [rescue] Committed uncommitted changes in workpiece`); } } catch (err) { logger.warn( ` [rescue] Failed to commit uncommitted changes: ${err instanceof Error ? err.message : String(err)}`, ); } // Get workpiece HEAD after potential commit let workpieceHead: string; try { workpieceHead = await git(workpieceDir, "rev-parse HEAD", io); } catch { return noop("no commits in workpiece"); } // Step 2: Get cache clone HEAD let cacheCloneHead: string; try { cacheCloneHead = await git(cacheCloneDir, "rev-parse HEAD", io); } catch { return noop("cache clone has no HEAD"); } // Step 3: Check if workpiece HEAD is already an ancestor of cache clone HEAD let isAncestor = false; const ancestorResult = await io.exec( "git", ["merge-base", "--is-ancestor", workpieceHead, cacheCloneHead], { cwd: cacheCloneDir }, ); isAncestor = ancestorResult.exitCode === 0; if (isAncestor) { return { rescued: committedChanges, committedChanges, commitSha: workpieceHead, mergedToCacheClone: true, pushedToBareRepo: true, bordbuchEvidence: false, backupDir: null, reason: committedChanges ? "workpiece HEAD already in cache clone" : "no new commits in workpiece", }; } // Step 4: Fetch workpiece branch into cache clone and merge --no-ff let workpieceBranch = "HEAD"; try { workpieceBranch = await git(workpieceDir, "rev-parse --abbrev-ref HEAD", io); } catch { // fall back to HEAD } try { await git( cacheCloneDir, `fetch ${JSON.stringify(workpieceDir)} ${JSON.stringify(workpieceBranch)}`, io, ); await git( cacheCloneDir, `merge --no-ff FETCH_HEAD -m "rescue: preserve workpiece edits before re-materialization ${missionId}"`, io, ); // Push merged commits to bare repo so they survive syncCacheClone's // git reset --hard origin/main on the next materialize. let pushedToBareRepo = false; let bordbuchEvidence = false; try { const branch = await git(cacheCloneDir, "rev-parse --abbrev-ref HEAD", io); await git(cacheCloneDir, `push origin ${JSON.stringify(branch)}`, io); pushedToBareRepo = true; logger.info(` [rescue] Pushed rescued commits to bare repo`); } catch (pushErr) { logger.warn( ` [rescue] Failed to push to bare repo — rescued commits in cache clone only: ${pushErr instanceof Error ? pushErr.message : String(pushErr)}`, ); // RFC-0954: Bordbuch evidence fallback — if push fails, record the rescue // in bordbuch so the operator knows rescued commits exist only in the // cache clone and may be lost on next syncCacheClone. if (workspaceRoot && systemId) { try { await appendAndCommitBordbuch( workspaceRoot, systemId, "operator-note" as BordbuchEntryKind, `Rescue push failed for ${missionId} — commits ${workpieceHead.slice(0, 8)} in cache clone only`, "mission:rescue", { missionId, writerRole: "operator", metadata: { rescueCommitSha: workpieceHead, pushError: pushErr instanceof Error ? pushErr.message : String(pushErr), warning: "Rescued commits may be lost on next syncCacheClone", }, }, `Bordbuch: rescue-push-failed ${systemId}`, ); bordbuchEvidence = true; logger.info(` [rescue] Recorded push failure in bordbuch as evidence`); } catch (bordbuchErr) { logger.warn( ` [rescue] Failed to record bordbuch evidence: ${bordbuchErr instanceof Error ? bordbuchErr.message : String(bordbuchErr)}`, ); } } } logger.info( ` [rescue] Merged workpiece edits into cache clone (${workpieceHead.slice(0, 8)})`, ); return { rescued: true, committedChanges, commitSha: workpieceHead, mergedToCacheClone: true, pushedToBareRepo, bordbuchEvidence, backupDir: null, reason: pushedToBareRepo ? "rescued successfully" : "rescued to cache clone only (push failed, bordbuch evidence recorded)", }; } catch (err) { // Merge failed — abort merge in cache clone await execOrThrow(io, "git", ["merge", "--abort"], { cwd: cacheCloneDir }).catch(() => {}); // Backup old workpiece so edits can be recovered manually const backupDir = `${workpieceDir}.rescue-${process.pid}-${Date.now()}`; try { await io.rename(workpieceDir, backupDir); logger.warn( ` [rescue] Merge failed — backed up old workpiece to ${path.basename(backupDir)}`, ); logger.warn(` [rescue] Merge error: ${err instanceof Error ? err.message : String(err)}`); } catch { logger.warn(` [rescue] Failed to backup old workpiece`); return noop("merge failed, backup also failed"); } return { rescued: false, committedChanges, commitSha: workpieceHead, mergedToCacheClone: false, pushedToBareRepo: false, bordbuchEvidence: false, backupDir, reason: "merge failed — old workpiece backed up for manual recovery", }; } }