diff --git a/docs/BEARING.md b/docs/BEARING.md index ac4da90..719354a 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -11,6 +11,11 @@ timeline ## Active Gravity +### 0. Local Mind Repairability +- Pulling `CORE_repair-v17-git-warp-minds` into cycle `0066`. +- Making the git-warp v17 checkpoint repair path repeatable for local minds. +- Keeping version-specific repair logic outside normal capture and read flows. + ### 1. Performance Hardening - Profiling CLI capture to identify Node startup and WARP graph bottlenecks. - Benchmark harness maturation for warm-path regression detection. @@ -33,4 +38,4 @@ timeline ## Next Target -The immediate focus is **Performance Profiling** to neutralize capture latency and ensure the "Sacred Capture" moment remains truly cheap. +The immediate focus is **Local Mind Repairability**: turn the manual git-warp v17 checkpoint recovery into an explicit, testable repair script for broken local minds. diff --git a/docs/design/0066-repair-v17-git-warp-minds/repair-v17-git-warp-minds.md b/docs/design/0066-repair-v17-git-warp-minds/repair-v17-git-warp-minds.md new file mode 100644 index 0000000..0d809c1 --- /dev/null +++ b/docs/design/0066-repair-v17-git-warp-minds/repair-v17-git-warp-minds.md @@ -0,0 +1,200 @@ +--- +title: "Repair local minds after git-warp v17" +legend: "CORE" +cycle: "0066-repair-v17-git-warp-minds" +source_backlog: "docs/method/backlog/asap/CORE_repair-v17-git-warp-minds.md" +--- + +# Repair local minds after git-warp v17 + +Source backlog item: `docs/method/backlog/asap/CORE_repair-v17-git-warp-minds.md` +Legend: CORE + +## Sponsors + +- Human: James +- Agent: Codex + +## Hill + +A local Think mind with an old git-warp checkpoint can be repaired by one +explicit command or script without rewriting graph history. The repair backs up +the old checkpoint ref, runs the git-warp schema upgrade, preserves legacy blob +content anchors, writes a fresh checkpoint, and leaves the mind readable. + +## Playback Questions + +### Human + +- [ ] Can I repair a named local mind, then run `-think --remember --json` + without the schema:4 checkpoint error? +- [ ] Can I see the dated pre-upgrade checkpoint backup ref if I need to audit + or recover the repair? + +### Agent + +- [ ] Does the repair flow resolve `--mind ` to `~/.think/` and use + graph `think` by default? +- [ ] Does a dry-run report the mind needs upgrade before repair and already + current after repair? +- [ ] Does the flow create + `refs/warp/think/checkpoints/pre-v17-upgrade-` before changing + the live checkpoint ref? +- [ ] Does the flow preserve legacy `_content_` anchors as `100644 blob` + entries when the referenced object is a raw Git blob? +- [ ] Does the flow write a fresh checkpoint after the upgrade? +- [ ] Does `git warp check` report `patchesSinceCheckpoint: 0` after repair? +- [ ] Does `git warp doctor` report zero failures, except explicitly documented + warnings? +- [ ] Does regression coverage include an old checkpoint whose `_content_` + anchor points at a blob? +- [ ] Does normal capture, remember, browse, and inspect code stay free of + v17-specific compatibility branches? + +## All postures + +Not applicable. This is local repair tooling for already-broken local minds. + +## Non-goals + +- Not moving Think onto Echo in this cycle. +- Not changing normal capture, remember, browse, inspect, or MCP read behavior. +- Not rewriting WARP graph history. +- Not making automatic repair happen during ordinary reads. +- Not repairing arbitrary non-Think git-warp repos. +- Not solving hosted sharing, remote relay, or multi-mind-in-one-repo identity. + +## Selected Path + +Start with a repo-owned script, `scripts/repair-v17-mind.mjs`, rather than a +new public CLI command. The backlog calls for "one command or scripted flow", +and a script keeps the compatibility path explicit while avoiding permanent +git-warp-version logic in the normal runtime. + +If the script proves stable, a later cycle can add a thin `think doctor --repair +--mind ` wrapper around the same implementation. + +## Design + +### Inputs + +The first repair surface should accept: + +```bash +node scripts/repair-v17-mind.mjs --mind claude +node scripts/repair-v17-mind.mjs --repo ~/.think/claude --graph think +node scripts/repair-v17-mind.mjs --mind claude --dry-run --json +``` + +Rules: + +- `--mind ` resolves to `~/.think/`. +- `--repo ` is allowed for fixtures and explicit local repair. +- `--graph` defaults to `think`. +- `--json` emits machine-readable stage results. +- `--dry-run` never changes refs or checkpoints. +- The command refuses to run without a Git repo at the target path. + +### Repair Stages + +1. Inspect the target checkpoint ref: + `refs/warp//checkpoints/head`. +2. Run the git-warp schema upgrade dry-run for the target repo and graph. +3. If repair is needed, create a dated backup ref: + `refs/warp//checkpoints/pre-v17-upgrade-`. +4. Run the git-warp schema upgrade for real. +5. Attempt a fresh checkpoint/materialization pass. +6. If checkpoint creation rejects a legacy `_content_` anchor because the + object is a raw blob, rebuild the checkpoint tree with that entry as + `100644 blob` rather than `040000 tree`. +7. Verify the repaired repo with upgrade dry-run, `git warp check`, and + `git warp doctor`. + +### Blob Anchor Rule + +Legacy Think minds may store capture content as raw Git blobs with synthetic +anchors named `_content_`. During checkpoint repair those anchors must +remain blob entries: + +```text +100644 blob _content_ +``` + +The repair must not assume every object reachable from a checkpoint tree is +itself a tree. If a candidate anchor names an object whose Git type is `blob`, +the repaired checkpoint tree entry must stay a blob entry. Treating it as +`040000 tree` makes Git reject the checkpoint tree and leaves the mind broken. + +### Backup Ref Invariant + +The live checkpoint ref may advance only after the pre-upgrade ref exists. + +The repair should fail closed if it cannot create the backup ref. A failed +backup means no schema upgrade and no checkpoint mutation. + +### Verification Surface + +The JSON result should expose enough detail for agents and retrospectives: + +```json +{ + "ok": true, + "repo": "/Users/james/.think/claude", + "graph": "think", + "backupRef": "refs/warp/think/checkpoints/pre-v17-upgrade-20260512-162500", + "beforeCheckpoint": "fe47a53d...", + "afterCheckpoint": "7b05c...", + "upgrade": { + "before": "needed", + "after": "already-current" + }, + "check": { + "patchesSinceCheckpoint": 0 + }, + "doctor": { + "failures": 0, + "warnings": [] + } +} +``` + +## Files To Modify + +- `scripts/repair-v17-mind.mjs` — explicit local repair script +- `test/acceptance/repair-v17-mind.test.js` — fixture-backed repair coverage +- `package.json` — optional npm alias if useful after the script is stable + +## Test Strategy + +Use a fixture repo generated inside the test temp directory. The fixture should +model the old checkpoint shape narrowly enough to exercise the Think-specific +repair invariant: + +- a schema:4-style checkpoint ref +- at least one legacy `_content_` anchor +- the anchor target is a Git `blob` +- repair creates a backup checkpoint ref +- repair writes a fresh checkpoint ref +- post-repair dry-run reports already-current + +Where practical, the test should execute the same script entry point an agent +would run. If git-warp v17 CLI behavior is not available in CI, split the core +ref/tree repair logic behind a small module and cover that module directly, +while keeping one integration smoke test for environments that have the v17 +toolchain. + +## Backlog Context + +The motivating failure was a local mind upgraded across git-warp v17 where +remember failed with: + +```text +Checkpoint is schema:4. Only schema:5 checkpoints are supported. +``` + +A controlled repair on `~/.think/claude` backed up the checkpoint at +`refs/warp/think/checkpoints/pre-v17-upgrade-20260505-102848`, wrote repaired +checkpoint `91f65c...`, later advanced to `7b05c...`, and finished with doctor +reporting zero failures. + +This cycle turns that manual recovery into repeatable Think-owned tooling. diff --git a/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md b/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md index 0532c13..47ef2ba 100644 --- a/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md +++ b/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md @@ -24,5 +24,7 @@ short-term guard, but it should not become permanent dependency sludge. documented local/workspace dependency path. - Checkpoint read tests pass from a clean install, not only from a local linked git-warp checkout. +- The archived v17 repair acceptance fixture runs its full repair assertion in + clean CI instead of skipping when the v17 migration package is unavailable. - The state-reader compatibility bridge is either documented as intentional version support or removed after the dependency cutover. diff --git a/package-lock.json b/package-lock.json index 018c6ec..a2b1312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@git-stunts/git-cas": "^5.3.2", "eslint": "^10.1.0", "globals": "^17.4.0" }, diff --git a/package.json b/package.json index 3e54d04..ec0f0ee 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "install-hooks": "git config --local core.hooksPath scripts/hooks", "tui": "node ./bin/think.js --browse", "benchmark:browse": "node benchmarks/browse-bootstrap.js", - "benchmark:capture": "node benchmarks/capture-latency.js" + "benchmark:capture": "node benchmarks/capture-latency.js", + "repair:v17-mind": "node ./scripts/repair-v17-mind.mjs" }, "engines": { "node": ">=22.0.0" @@ -46,6 +47,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@git-stunts/git-cas": "^5.3.2", "eslint": "^10.1.0", "globals": "^17.4.0" } diff --git a/scripts/repair-v17-mind.mjs b/scripts/repair-v17-mind.mjs new file mode 100644 index 0000000..f87d6e2 --- /dev/null +++ b/scripts/repair-v17-mind.mjs @@ -0,0 +1,813 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; + +const GRAPH_DEFAULT = 'think'; +const NULL_OID = '0000000000000000000000000000000000000000'; +const CONTENT_ANCHOR_RE = /^(?100644|040000) (?blob|tree) (?[0-9a-f]{40,64})\t_content_(?[0-9a-f]{40,64})$/u; +const requireFromScript = createRequire(import.meta.url); + +export class RepairV17MindError extends Error { + constructor(message, { code = 'repair_v17_mind.error', details = {} } = {}) { + super(message); + this.name = 'RepairV17MindError'; + this.code = code; + this.details = details; + } +} + +export function usage() { + return [ + 'Usage:', + ' node scripts/repair-v17-mind.mjs --mind [--graph think] [--dry-run] [--json]', + ' node scripts/repair-v17-mind.mjs --repo [--graph think] [--dry-run] [--json]', + '', + 'Options:', + ' --mind Resolve a local mind under ~/.think/. The special name "default" maps to ~/.think/repo.', + ' --repo Explicit Think mind Git repository path.', + ' --graph Graph to repair. Defaults to think.', + ' --dry-run Inspect and report without changing refs or checkpoints.', + ' --json Emit machine-readable JSON.', + ' --help, -h Show this help.', + ].join('\n'); +} + +export function parseRepairArgs(argv) { + const args = { + dryRun: false, + graph: GRAPH_DEFAULT, + help: false, + json: false, + mind: null, + repo: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--mind') { + args.mind = requireValue(argv, index, '--mind'); + index += 1; + continue; + } + if (arg === '--repo') { + args.repo = requireValue(argv, index, '--repo'); + index += 1; + continue; + } + if (arg === '--graph') { + args.graph = requireValue(argv, index, '--graph'); + index += 1; + continue; + } + if (arg === '--dry-run') { + args.dryRun = true; + continue; + } + if (arg === '--json') { + args.json = true; + continue; + } + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + throw new RepairV17MindError(`Unknown argument: ${arg ?? ''}`, { + code: 'repair_v17_mind.usage', + }); + } + + return Object.freeze(args); +} + +function requireValue(argv, index, flag) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw new RepairV17MindError(`${flag} requires a value`, { + code: 'repair_v17_mind.usage', + }); + } + return value; +} + +export function resolveRepairTarget(args, { + cwd = process.cwd(), + homeDir = process.env.HOME || os.homedir(), +} = {}) { + if (args.mind !== null && args.repo !== null) { + throw new RepairV17MindError('Use either --mind or --repo, not both', { + code: 'repair_v17_mind.usage', + }); + } + + if (args.mind === null && args.repo === null) { + throw new RepairV17MindError('Repair target required: pass --mind or --repo ', { + code: 'repair_v17_mind.usage', + }); + } + + const graph = normalizeGraphName(args.graph); + if (args.repo !== null) { + return Object.freeze({ + graph, + mind: null, + repoDir: path.resolve(cwd, args.repo), + }); + } + + const mind = normalizeMindName(args.mind); + const mindDirName = mind === 'default' ? 'repo' : mind; + return Object.freeze({ + graph, + mind, + repoDir: path.join(homeDir, '.think', mindDirName), + }); +} + +function normalizeGraphName(graph) { + const value = String(graph ?? '').trim(); + if (value.length === 0) { + throw new RepairV17MindError('--graph must not be empty', { + code: 'repair_v17_mind.usage', + }); + } + if (value.includes('/') || value.includes('\\')) { + throw new RepairV17MindError('--graph must be a graph name, not a path', { + code: 'repair_v17_mind.usage', + }); + } + return value; +} + +function normalizeMindName(mind) { + const value = String(mind ?? '').trim(); + if (value.length === 0) { + throw new RepairV17MindError('--mind must not be empty', { + code: 'repair_v17_mind.usage', + }); + } + if (value.includes('/') || value.includes('\\') || value === '.' || value === '..') { + throw new RepairV17MindError('--mind must be one local mind name, not a path', { + code: 'repair_v17_mind.usage', + }); + } + return value; +} + +export async function repairV17Mind(options, deps = {}) { + const target = resolveRepairTarget(options, deps); + const runner = deps.runner ?? runCommand; + const now = deps.now ?? new Date(); + const runUpgrade = deps.runUpgrade ?? runGitWarpUpgrade; + const materialize = deps.materialize ?? materializeGraph; + const runCheck = deps.runCheck ?? checkGraph; + const runDoctor = deps.runDoctor ?? doctorGraph; + + assertGitRepo(target.repoDir, runner); + + const checkpointRef = buildCheckpointRef(target.graph); + const beforeCheckpoint = readCheckpointSha(target.repoDir, checkpointRef, runner); + const upgradeBefore = await runUpgrade({ + graph: target.graph, + repoDir: target.repoDir, + dryRun: true, + runner, + }); + + if (options.dryRun) { + return Object.freeze({ + ok: true, + dryRun: true, + repo: target.repoDir, + graph: target.graph, + checkpointRef, + beforeCheckpoint, + backupRef: null, + changed: false, + upgrade: { + before: normalizeUpgradeStatus(upgradeBefore), + after: null, + rawBefore: upgradeBefore, + rawAfter: null, + }, + wouldRepair: upgradeBefore.status === 'would-upgrade', + }); + } + + if (upgradeBefore.status !== 'would-upgrade') { + return Object.freeze({ + ok: true, + dryRun: false, + repo: target.repoDir, + graph: target.graph, + checkpointRef, + beforeCheckpoint, + backupRef: null, + changed: false, + upgrade: { + before: normalizeUpgradeStatus(upgradeBefore), + after: normalizeUpgradeStatus(upgradeBefore), + rawBefore: upgradeBefore, + rawAfter: upgradeBefore, + }, + materialize: null, + check: null, + doctor: null, + }); + } + + if (beforeCheckpoint === null) { + throw new RepairV17MindError('Upgrade dry-run reported work, but no checkpoint ref exists to back up', { + code: 'repair_v17_mind.inconsistent_checkpoint', + details: { checkpointRef, repo: target.repoDir }, + }); + } + + const backupRef = createBackupRef({ + checkpointSha: beforeCheckpoint, + graph: target.graph, + now, + repoDir: target.repoDir, + runner, + }); + + const upgradeActual = await runUpgrade({ + graph: target.graph, + repoDir: target.repoDir, + dryRun: false, + runner, + }); + const materializeResult = await materialize({ + graph: target.graph, + repoDir: target.repoDir, + runner, + }); + const upgradeAfter = await runUpgrade({ + graph: target.graph, + repoDir: target.repoDir, + dryRun: true, + runner, + }); + const checkResult = await runCheck({ + graph: target.graph, + repoDir: target.repoDir, + runner, + }); + const doctorResult = await runDoctor({ + graph: target.graph, + repoDir: target.repoDir, + runner, + }); + + validatePostRepair({ + checkResult, + doctorResult, + upgradeAfter, + }); + + return Object.freeze({ + ok: true, + dryRun: false, + repo: target.repoDir, + graph: target.graph, + checkpointRef, + backupRef, + beforeCheckpoint, + afterCheckpoint: resolveAfterCheckpoint({ + checkResult, + materializeResult, + upgradeActual, + }), + changed: true, + upgrade: { + before: normalizeUpgradeStatus(upgradeBefore), + after: normalizeUpgradeStatus(upgradeAfter), + rawBefore: upgradeBefore, + rawActual: upgradeActual, + rawAfter: upgradeAfter, + }, + materialize: materializeResult, + check: { + patchesSinceCheckpoint: checkResult.status?.patchesSinceCheckpoint ?? null, + raw: checkResult, + }, + doctor: { + failures: doctorResult.summary?.fail ?? null, + warnings: collectDoctorWarnings(doctorResult), + raw: doctorResult, + }, + }); +} + +function assertGitRepo(repoDir, runner) { + const result = runGit(repoDir, ['rev-parse', '--git-dir'], runner, { allowExitCodes: [0, 128] }); + if (result.status === 0) { + return; + } + + throw new RepairV17MindError(`Repair target is not a Git repository: ${repoDir}`, { + code: 'repair_v17_mind.repo_not_found', + details: { repo: repoDir }, + }); +} + +export function buildCheckpointRef(graph) { + return `refs/warp/${graph}/checkpoints/head`; +} + +function readCheckpointSha(repoDir, checkpointRef, runner) { + const result = runGit(repoDir, ['rev-parse', '--verify', '--quiet', checkpointRef], runner, { + allowExitCodes: [0, 1], + }); + if (result.status === 0) { + return result.stdout.trim(); + } + return null; +} + +export function createBackupRef({ checkpointSha, graph, now, repoDir, runner = runCommand }) { + for (let index = 0; index < 100; index += 1) { + const backupRef = buildBackupRef(graph, now, index); + if (refExists(repoDir, backupRef, runner)) { + continue; + } + + runGit(repoDir, ['update-ref', '--create-reflog', backupRef, checkpointSha, NULL_OID], runner); + return backupRef; + } + + throw new RepairV17MindError('Could not allocate a unique pre-v17 checkpoint backup ref', { + code: 'repair_v17_mind.backup_ref_exhausted', + details: { graph, repo: repoDir }, + }); +} + +function buildBackupRef(graph, now, index) { + const suffix = index === 0 ? '' : `-${String(index + 1).padStart(2, '0')}`; + return `refs/warp/${graph}/checkpoints/pre-v17-upgrade-${formatBackupTimestamp(now)}${suffix}`; +} + +function refExists(repoDir, ref, runner) { + const result = runGit(repoDir, ['show-ref', '--verify', '--quiet', ref], runner, { + allowExitCodes: [0, 1], + }); + return result.status === 0; +} + +export function formatBackupTimestamp(date) { + const parts = [ + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ]; + const [year, month, day, hour, minute, second] = parts.map(part => String(part).padStart(2, '0')); + return `${year}${month}${day}-${hour}${minute}${second}`; +} + +export async function runGitWarpUpgrade({ repoDir, graph, dryRun, runner = runCommand }) { + const packageRoot = resolveGitWarpPackageRoot(); + const upgradeModule = await importGitWarpUpgradeModule(packageRoot); + const persistence = await createV17Persistence({ repoDir, runner }); + const cryptoAdapter = await createV17Crypto(packageRoot); + + return await upgradeModule.upgradeCheckpointSchema({ + persistence, + graphName: graph, + dryRun, + crypto: cryptoAdapter, + }); +} + +async function importGitWarpUpgradeModule(packageRoot) { + const upgradePath = path.join( + packageRoot, + 'dist', + 'scripts', + 'migrations', + 'v17.0.0', + 'checkpoint-schema-upgrade.js' + ); + if (!existsSync(upgradePath)) { + throw new RepairV17MindError('Installed @git-stunts/git-warp does not expose the v17 checkpoint migration script', { + code: 'repair_v17_mind.v17_upgrade_unavailable', + details: { upgradePath }, + }); + } + + return await import(pathToFileURL(upgradePath).href); +} + +async function createV17Crypto(packageRoot) { + const cryptoPath = path.join( + packageRoot, + 'dist', + 'src', + 'infrastructure', + 'adapters', + 'NodeCryptoAdapter.js' + ); + if (!existsSync(cryptoPath)) { + return undefined; + } + + const cryptoModule = await import(pathToFileURL(cryptoPath).href); + const NodeCryptoAdapter = cryptoModule.default ?? cryptoModule.NodeCryptoAdapter; + if (typeof NodeCryptoAdapter !== 'function') { + return undefined; + } + return new NodeCryptoAdapter(); +} + +async function createV17Persistence({ repoDir, runner }) { + const gitWarp = await import('@git-stunts/git-warp'); + const plumbingModule = await import('@git-stunts/plumbing'); + const { GitGraphAdapter } = gitWarp; + const Plumbing = plumbingModule.default ?? plumbingModule; + const persistence = new GitGraphAdapter({ + plumbing: Plumbing.createDefault({ cwd: repoDir }), + }); + + return createContentAnchorAwarePersistence( + persistence, + createGitObjectTypeReader({ repoDir, runner }) + ); +} + +export async function materializeGraph({ repoDir, graph, runner = runCommand }) { + const gitWarp = await import('@git-stunts/git-warp'); + const plumbingModule = await import('@git-stunts/plumbing'); + const { default: DefaultWarpApp, GitGraphAdapter, WarpApp: NamedWarpApp } = gitWarp; + const WarpApp = DefaultWarpApp ?? NamedWarpApp; + const Plumbing = plumbingModule.default ?? plumbingModule; + const persistence = createContentAnchorAwarePersistence( + new GitGraphAdapter({ + plumbing: Plumbing.createDefault({ cwd: repoDir }), + }), + createGitObjectTypeReader({ repoDir, runner }) + ); + const app = await WarpApp.open({ + persistence, + graphName: graph, + writerId: 'think-repair-v17', + checkpointPolicy: { every: 100 }, + }); + const state = await app.core().materialize(); + const checkpoint = await app.core().createCheckpoint(); + + return Object.freeze({ + graph, + checkpoint, + edges: sizeOf(state?.edgeAlive), + nodes: sizeOf(state?.nodeAlive), + properties: sizeOf(state?.prop), + }); +} + +function sizeOf(value) { + if (typeof value?.size === 'number') { + return value.size; + } + if (typeof value?.count === 'number') { + return value.count; + } + return null; +} + +export function checkGraph({ repoDir, graph, runner = runCommand }) { + return runGitWarpJsonCommand({ + command: 'check', + graph, + repoDir, + runner, + }); +} + +export function doctorGraph({ repoDir, graph, runner = runCommand }) { + return runGitWarpJsonCommand({ + command: 'doctor', + graph, + repoDir, + runner, + allowExitCodes: [0, 3], + }); +} + +function runGitWarpJsonCommand({ + allowExitCodes = [0], + command, + graph, + repoDir, + runner, +}) { + const gitWarp = resolveGitWarpBinCommand(); + const result = runner(gitWarp.command, [ + ...gitWarp.args, + '--repo', + repoDir, + '--graph', + graph, + '--json', + command, + ], { + allowExitCodes, + }); + return parseJsonCommandOutput(result.stdout, command); +} + +export function createContentAnchorAwarePersistence(persistence, readObjectType) { + return new Proxy(persistence, { + get(target, property, receiver) { + if (property === 'writeTree') { + return async (entries) => { + const normalized = await normalizeContentAnchorTreeEntries(entries, readObjectType); + return await target.writeTree(normalized); + }; + } + + const value = Reflect.get(target, property, receiver); + if (typeof value === 'function') { + return value.bind(target); + } + return value; + }, + }); +} + +export async function normalizeContentAnchorTreeEntries(entries, readObjectType) { + return await Promise.all(entries.map(async (entry) => { + const parsed = parseContentAnchorEntry(entry); + if (parsed === null) { + return entry; + } + + const objectType = await readObjectType(parsed.oid); + if (objectType === 'blob') { + return `100644 blob ${parsed.oid}\t_content_${parsed.oid}`; + } + if (objectType === 'tree') { + return `040000 tree ${parsed.oid}\t_content_${parsed.oid}`; + } + + throw new RepairV17MindError(`Unsupported content anchor object type: ${objectType}`, { + code: 'repair_v17_mind.unsupported_content_anchor_type', + details: { objectType, oid: parsed.oid }, + }); + })); +} + +function parseContentAnchorEntry(entry) { + const match = CONTENT_ANCHOR_RE.exec(entry); + if (match === null) { + return null; + } + + const groups = match.groups ?? {}; + if (groups.oid !== groups.anchorOid) { + return null; + } + + return Object.freeze({ + kind: groups.kind, + mode: groups.mode, + oid: groups.oid, + }); +} + +function createGitObjectTypeReader({ repoDir, runner }) { + return (oid) => { + const result = runGit(repoDir, ['cat-file', '-t', oid], runner); + return result.stdout.trim(); + }; +} + +export function resolveGitWarpPackageRoot(packageName = '@git-stunts/git-warp') { + const packageEntry = requireFromScript.resolve(packageName); + let dir = path.dirname(packageEntry); + while (dir !== path.dirname(dir)) { + const packageJsonPath = path.join(dir, 'package.json'); + if (existsSync(packageJsonPath)) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + if (packageJson.name === packageName) { + return dir; + } + } + dir = path.dirname(dir); + } + + throw new RepairV17MindError(`Could not resolve ${packageName} package root`, { + code: 'repair_v17_mind.package_root_unavailable', + details: { packageName }, + }); +} + +function resolveGitWarpBinCommand() { + const packageRoot = resolveGitWarpPackageRoot(); + const packageJson = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); + const bin = typeof packageJson.bin === 'string' + ? packageJson.bin + : packageJson.bin?.['git-warp']; + const relativeBin = bin ?? 'bin/git-warp'; + const binPath = path.join(packageRoot, relativeBin); + if (!existsSync(binPath)) { + throw new RepairV17MindError('Installed @git-stunts/git-warp does not expose a git-warp binary', { + code: 'repair_v17_mind.git_warp_bin_unavailable', + details: { binPath }, + }); + } + + return Object.freeze({ + command: process.execPath, + args: [binPath], + }); +} + +function runGit(repoDir, args, runner, { allowExitCodes = [0] } = {}) { + return runner('git', ['-C', repoDir, ...args], { + allowExitCodes, + }); +} + +export function runCommand(command, args, { + allowExitCodes = [0], + cwd = process.cwd(), + env = {}, +} = {}) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + NO_COLOR: '1', + ...env, + }, + }); + if (result.error) { + throw new RepairV17MindError(result.error.message, { + code: 'repair_v17_mind.command_error', + details: { args, command }, + }); + } + + const status = result.status ?? 1; + if (!allowExitCodes.includes(status)) { + throw new RepairV17MindError(`Command failed: ${command} ${args.join(' ')}`, { + code: 'repair_v17_mind.command_failed', + details: { + args, + command, + status, + stderr: result.stderr ?? '', + stdout: result.stdout ?? '', + }, + }); + } + + return Object.freeze({ + args, + command, + status, + stderr: result.stderr ?? '', + stdout: result.stdout ?? '', + }); +} + +function parseJsonCommandOutput(stdout, label) { + try { + return JSON.parse(stdout); + } catch (error) { + throw new RepairV17MindError(`Could not parse JSON output from ${label}`, { + code: 'repair_v17_mind.invalid_json', + details: { + cause: error instanceof Error ? error.message : String(error), + stdout, + }, + }); + } +} + +function normalizeUpgradeStatus(result) { + if (result.status === 'would-upgrade') { + return 'needed'; + } + return result.status; +} + +function validatePostRepair({ checkResult, doctorResult, upgradeAfter }) { + if (upgradeAfter.status !== 'already-current') { + throw new RepairV17MindError('Post-repair upgrade dry-run did not report already-current', { + code: 'repair_v17_mind.upgrade_not_current', + details: { upgradeAfter }, + }); + } + + const patchesSinceCheckpoint = checkResult.status?.patchesSinceCheckpoint; + if (patchesSinceCheckpoint !== 0) { + throw new RepairV17MindError('Post-repair git-warp check reported patches since checkpoint', { + code: 'repair_v17_mind.check_not_fresh', + details: { patchesSinceCheckpoint }, + }); + } + + const failures = doctorResult.summary?.fail; + if (failures !== 0) { + throw new RepairV17MindError('Post-repair git-warp doctor reported failures', { + code: 'repair_v17_mind.doctor_failures', + details: { failures, doctorResult }, + }); + } +} + +function resolveAfterCheckpoint({ checkResult, materializeResult, upgradeActual }) { + return checkResult.checkpoint?.sha + ?? materializeResult.checkpoint + ?? upgradeActual.upgradedCheckpointSha + ?? null; +} + +function collectDoctorWarnings(doctorResult) { + const findings = Array.isArray(doctorResult.findings) ? doctorResult.findings : []; + return findings.filter(finding => finding?.status === 'warn'); +} + +function formatHumanResult(result) { + if (result.dryRun) { + return [ + `Repo: ${result.repo}`, + `Graph: ${result.graph}`, + `Checkpoint: ${result.beforeCheckpoint ?? '(missing)'}`, + `Upgrade: ${result.upgrade.before}`, + `Would repair: ${String(result.wouldRepair)}`, + ].join('\n'); + } + + if (!result.changed) { + return [ + `Repo: ${result.repo}`, + `Graph: ${result.graph}`, + `No repair needed: ${result.upgrade.before}`, + ].join('\n'); + } + + return [ + `Repaired ${result.repo} graph ${result.graph}.`, + `Backup: ${result.backupRef}`, + `Before: ${result.beforeCheckpoint}`, + `After: ${result.afterCheckpoint ?? '(unknown)'}`, + `Patches since checkpoint: ${String(result.check.patchesSinceCheckpoint)}`, + `Doctor failures: ${String(result.doctor.failures)}`, + `Doctor warnings: ${String(result.doctor.warnings.length)}`, + ].join('\n'); +} + +async function main() { + const args = parseRepairArgs(process.argv.slice(2)); + if (args.help) { + process.stdout.write(`${usage()}\n`); + return; + } + + const result = await repairV17Mind(args); + if (args.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + process.stdout.write(`${formatHumanResult(result)}\n`); +} + +function isMainModule() { + const invokedPath = process.argv[1]; + if (invokedPath === undefined) { + return false; + } + + return path.resolve(invokedPath) === fileURLToPath(import.meta.url); +} + +if (isMainModule()) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + const code = error instanceof RepairV17MindError ? error.code : 'repair_v17_mind.internal'; + const details = error instanceof RepairV17MindError ? error.details : {}; + if (process.argv.includes('--json')) { + process.stdout.write(`${JSON.stringify({ + ok: false, + error: { + code, + message, + details, + }, + }, null, 2)}\n`); + } else { + process.stderr.write(`${message}\n\n${usage()}\n`); + } + process.exitCode = 1; + }); +} diff --git a/test/acceptance/repair-v17-mind.test.js b/test/acceptance/repair-v17-mind.test.js new file mode 100644 index 0000000..d517b5b --- /dev/null +++ b/test/acceptance/repair-v17-mind.test.js @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { + resolveGitWarpPackageRoot, +} from '../../scripts/repair-v17-mind.mjs'; +import { baseEnv, formatResult, repoRoot } from '../fixtures/runtime.js'; +import { createTempDir } from '../fixtures/tmp.js'; +import { assertSuccess } from '../support/assertions.js'; + +const FIXTURE_METADATA_PATH = path.join(repoRoot, 'test', 'fixtures', 'cas', 'gemini-pre-v17-mind.json'); +const GIT_CAS_ENTRYPOINT = path.join(repoRoot, 'node_modules', '@git-stunts', 'git-cas', 'bin', 'git-cas.js'); +const REPAIR_ENTRYPOINT = path.join(repoRoot, 'scripts', 'repair-v17-mind.mjs'); +const RESTORE_TIMEOUT_MS = 120_000; +const GRAPH = 'think'; + +test('git-cas fixture restores an archived pre-v17 Gemini mind tarball', async () => { + const fixture = await readGeminiFixtureMetadata(); + const { mindDir, tarballPath } = await restoreGeminiFixture(fixture); + + assert.ok(existsSync(tarballPath), 'Expected git-cas restore to recreate the fixture tarball.'); + assert.ok(existsSync(path.join(mindDir, '.git')), 'Expected tar extraction to recreate a Git repository.'); + + const checkpoint = runGit(mindDir, ['rev-parse', '--verify', `refs/warp/${GRAPH}/checkpoints/head`]); + assertSuccess(checkpoint, 'Expected restored fixture to expose the old git-warp checkpoint ref.'); + assert.equal(checkpoint.stdout.trim(), fixture.source.checkpoint); + + const checkpointLog = runGit(mindDir, ['log', '-1', '--format=%B', fixture.source.checkpoint]); + assertSuccess(checkpointLog, 'Expected restored checkpoint to be readable as a Git commit.'); + assert.match( + checkpointLog.stdout, + new RegExp(`^eg-schema: ${fixture.source.schema}$`, 'm'), + 'Expected the restored fixture checkpoint to preserve schema 4.' + ); +}); + +test('repair-v17 mind repairs the restored git-cas fixture when v17 migration is installed', { + timeout: RESTORE_TIMEOUT_MS, +}, async (t) => { + if (!hasV17GitWarpMigration()) { + t.skip('Installed @git-stunts/git-warp package does not expose the v17 checkpoint migration.'); + return; + } + + const fixture = await readGeminiFixtureMetadata(); + const { mindDir } = await restoreGeminiFixture(fixture); + + const before = parseRepairJson( + runRepair(mindDir, ['--dry-run']), + 'Expected dry-run repair to inspect the restored fixture.' + ); + assert.equal(before.ok, true); + assert.equal(before.wouldRepair, true); + assert.equal(before.beforeCheckpoint, fixture.source.checkpoint); + assert.equal(before.upgrade.before, 'needed'); + assert.equal(before.upgrade.rawBefore.previousSchema, fixture.source.schema); + assert.equal(before.upgrade.rawBefore.currentSchema, fixture.expected.upgradedSchema); + + const repaired = parseRepairJson( + runRepair(mindDir), + 'Expected repair to upgrade and materialize the restored fixture.' + ); + assert.equal(repaired.ok, true); + assert.equal(repaired.changed, true); + assert.equal(repaired.beforeCheckpoint, fixture.source.checkpoint); + assert.notEqual(repaired.afterCheckpoint, fixture.source.checkpoint); + assert.match( + repaired.backupRef, + /^refs\/warp\/think\/checkpoints\/pre-v17-upgrade-\d{8}-\d{6}(?:-\d{2})?$/u + ); + assert.equal(repaired.upgrade.before, 'needed'); + assert.equal(repaired.upgrade.after, 'already-current'); + assert.equal(repaired.check.patchesSinceCheckpoint, 0); + assert.equal(repaired.doctor.failures, 0); + + const after = parseRepairJson( + runRepair(mindDir, ['--dry-run']), + 'Expected repaired fixture to be current on a follow-up dry run.' + ); + assert.equal(after.ok, true); + assert.equal(after.wouldRepair, false); + assert.equal(after.upgrade.before, 'already-current'); +}); + +async function readGeminiFixtureMetadata() { + const contents = await readFile(FIXTURE_METADATA_PATH, 'utf8'); + return JSON.parse(contents); +} + +async function restoreGeminiFixture(fixture) { + const parentDir = await createTempDir('think-gemini-pre-v17-fixture-'); + const mindDir = path.join(parentDir, 'mind'); + const tarballPath = path.join(parentDir, fixture.tarball.name); + + assert.ok( + existsSync(GIT_CAS_ENTRYPOINT), + `Expected git-cas test dependency to exist at ${GIT_CAS_ENTRYPOINT}` + ); + + const restore = spawnSync(process.execPath, [ + GIT_CAS_ENTRYPOINT, + '--json', + 'restore', + '--oid', + fixture.treeOid, + '--out', + tarballPath, + '--cwd', + repoRoot, + ], { + cwd: repoRoot, + encoding: 'utf8', + env: fixtureEnv(), + }); + assertSuccess(restore, 'Expected git-cas to restore the archived Gemini fixture tarball.'); + await assertRestoredTarball(tarballPath, fixture); + + await mkdir(mindDir, { recursive: true }); + const extract = spawnSync('tar', ['-xzf', tarballPath, '-C', mindDir], { + cwd: repoRoot, + encoding: 'utf8', + env: fixtureEnv(), + }); + assertSuccess(extract, 'Expected tar to extract the git-cas restored Gemini fixture.'); + + return Object.freeze({ + mindDir, + parentDir, + tarballPath, + }); +} + +function runRepair(mindDir, extraArgs = []) { + return spawnSync(process.execPath, [ + REPAIR_ENTRYPOINT, + '--repo', + mindDir, + '--graph', + GRAPH, + '--json', + ...extraArgs, + ], { + cwd: repoRoot, + encoding: 'utf8', + env: fixtureEnv(), + }); +} + +function runGit(repoDir, args) { + return spawnSync('git', ['-C', repoDir, ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: fixtureEnv(), + }); +} + +function parseRepairJson(result, message) { + assertSuccess(result, message); + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new assert.AssertionError({ + message: `${message}\nCould not parse repair JSON: ${String(error)}\n${formatResult(result)}`, + }); + } +} + +async function assertRestoredTarball(tarballPath, fixture) { + const contents = await readFile(tarballPath); + assert.equal(contents.byteLength, fixture.tarball.bytes); + assert.equal( + createHash('sha256').update(contents).digest('hex'), + fixture.tarball.sha256 + ); +} + +function hasV17GitWarpMigration() { + try { + const packageRoot = resolveGitWarpPackageRoot(); + return existsSync(path.join( + packageRoot, + 'dist', + 'scripts', + 'migrations', + 'v17.0.0', + 'checkpoint-schema-upgrade.js' + )); + } catch { + return false; + } +} + +function fixtureEnv() { + return { + ...process.env, + ...baseEnv, + COPYFILE_DISABLE: '1', + }; +} diff --git a/test/fixtures/cas/gemini-pre-v17-mind.json b/test/fixtures/cas/gemini-pre-v17-mind.json new file mode 100644 index 0000000..d9d6e83 --- /dev/null +++ b/test/fixtures/cas/gemini-pre-v17-mind.json @@ -0,0 +1,19 @@ +{ + "description": "Archived Gemini mind fixture captured before git-warp v17 checkpoint repair.", + "slug": "test-fixtures/gemini-pre-v17-mind-v1", + "treeOid": "5e84c970995f4d9c7135b9ce382e80894af0fee6", + "tarball": { + "name": "think-gemini-pre-v17-mind.tar.gz", + "bytes": 1585268, + "sha256": "24aade22b1cfce39dfe61e47a2acabc14d43520063a4a67567f305f393435e8c" + }, + "source": { + "mind": "gemini", + "graph": "think", + "checkpoint": "9c6288a985186ec69a0223e4f0bd1a83d71e38aa", + "schema": 4 + }, + "expected": { + "upgradedSchema": 5 + } +} diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/06304ef0fcf35923d5c088b0754a5f9dbabbb911385cd474843b873f956d99d5 b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/06304ef0fcf35923d5c088b0754a5f9dbabbb911385cd474843b873f956d99d5 new file mode 100644 index 0000000..c73c9dd Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/06304ef0fcf35923d5c088b0754a5f9dbabbb911385cd474843b873f956d99d5 differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/1631d123a05c5344f3cdb66aa1c43e0483d77394b19cb1ce39b8b714574e7020 b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/1631d123a05c5344f3cdb66aa1c43e0483d77394b19cb1ce39b8b714574e7020 new file mode 100644 index 0000000..f6314b6 Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/1631d123a05c5344f3cdb66aa1c43e0483d77394b19cb1ce39b8b714574e7020 differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/40448c26d481ee8edc2cd363c08594010390e634d1df3077e96e559600f4d832 b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/40448c26d481ee8edc2cd363c08594010390e634d1df3077e96e559600f4d832 new file mode 100644 index 0000000..8ee3bf6 Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/40448c26d481ee8edc2cd363c08594010390e634d1df3077e96e559600f4d832 differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/6c2be1d5240e9ba03cfe0a39a8377f4c64a025204f82a4040823986e9f2d3bd4 b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/6c2be1d5240e9ba03cfe0a39a8377f4c64a025204f82a4040823986e9f2d3bd4 new file mode 100644 index 0000000..4b38133 Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/6c2be1d5240e9ba03cfe0a39a8377f4c64a025204f82a4040823986e9f2d3bd4 differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/a661898208aa48494e824fe6cde36b8627a2dedfe2eda1411155bd266cc5c1cc b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/a661898208aa48494e824fe6cde36b8627a2dedfe2eda1411155bd266cc5c1cc new file mode 100644 index 0000000..63d07b0 Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/a661898208aa48494e824fe6cde36b8627a2dedfe2eda1411155bd266cc5c1cc differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/b3ced22628526ea02ddee188ae2a437c342cb79073285bdfd5ee4b25f31d9950 b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/b3ced22628526ea02ddee188ae2a437c342cb79073285bdfd5ee4b25f31d9950 new file mode 100644 index 0000000..94cb8d1 Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/b3ced22628526ea02ddee188ae2a437c342cb79073285bdfd5ee4b25f31d9950 differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/c5d80795e88d98515b5a0672aff167f74ef973d60bfa2e29afc5d1b72faa9fbc b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/c5d80795e88d98515b5a0672aff167f74ef973d60bfa2e29afc5d1b72faa9fbc new file mode 100644 index 0000000..fc5313d Binary files /dev/null and b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/c5d80795e88d98515b5a0672aff167f74ef973d60bfa2e29afc5d1b72faa9fbc differ diff --git a/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/manifest.json b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/manifest.json new file mode 100644 index 0000000..d1b34de --- /dev/null +++ b/test/fixtures/cas/gemini-pre-v17-mind.tar.cas/manifest.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "slug": "test-fixtures/gemini-pre-v17-mind-v1", + "filename": "think-gemini-pre-v17-mind.tar.gz", + "size": 1585268, + "chunks": [ + { + "index": 0, + "size": 262144, + "digest": "06304ef0fcf35923d5c088b0754a5f9dbabbb911385cd474843b873f956d99d5", + "blob": "c73c9dd4a188ab0f76f59a213c9ffc5db68a44bd" + }, + { + "index": 1, + "size": 262144, + "digest": "c5d80795e88d98515b5a0672aff167f74ef973d60bfa2e29afc5d1b72faa9fbc", + "blob": "fc5313dd10a58bb030ddd9038196b8d9cf709153" + }, + { + "index": 2, + "size": 262144, + "digest": "6c2be1d5240e9ba03cfe0a39a8377f4c64a025204f82a4040823986e9f2d3bd4", + "blob": "4b38133d9187ed159c8e0bf88b419c8f89725e3a" + }, + { + "index": 3, + "size": 262144, + "digest": "40448c26d481ee8edc2cd363c08594010390e634d1df3077e96e559600f4d832", + "blob": "8ee3bf6f4e9ccbd769cf6b13085fa4ddb571f4cc" + }, + { + "index": 4, + "size": 262144, + "digest": "b3ced22628526ea02ddee188ae2a437c342cb79073285bdfd5ee4b25f31d9950", + "blob": "94cb8d1b3b09cdc1872366b5b581d5e523717031" + }, + { + "index": 5, + "size": 262144, + "digest": "a661898208aa48494e824fe6cde36b8627a2dedfe2eda1411155bd266cc5c1cc", + "blob": "63d07b0112dc22375cbe9cb4b302f51fa623a806" + }, + { + "index": 6, + "size": 12404, + "digest": "1631d123a05c5344f3cdb66aa1c43e0483d77394b19cb1ce39b8b714574e7020", + "blob": "f6314b6f844d2c673c542cc07d802e1909b264a5" + } + ] +} \ No newline at end of file diff --git a/test/ports/repair-v17-mind.test.js b/test/ports/repair-v17-mind.test.js new file mode 100644 index 0000000..44d430b --- /dev/null +++ b/test/ports/repair-v17-mind.test.js @@ -0,0 +1,291 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createBackupRef, + formatBackupTimestamp, + normalizeContentAnchorTreeEntries, + parseRepairArgs, + repairV17Mind, + resolveRepairTarget, +} from '../../scripts/repair-v17-mind.mjs'; + +const OLD_CHECKPOINT = '1111111111111111111111111111111111111111'; +const UPGRADED_CHECKPOINT = '2222222222222222222222222222222222222222'; +const FRESH_CHECKPOINT = '3333333333333333333333333333333333333333'; +const ZERO_OID = '0000000000000000000000000000000000000000'; + +test('repair args resolve a named mind under ~/.think', () => { + const args = parseRepairArgs(['--mind', 'claude', '--dry-run', '--json']); + const target = resolveRepairTarget(args, { + homeDir: '/Users/example', + }); + + assert.equal(target.graph, 'think'); + assert.equal(target.mind, 'claude'); + assert.equal(target.repoDir, path.join('/Users/example', '.think', 'claude')); +}); + +test('repair args map the default mind to ~/.think/repo', () => { + const args = parseRepairArgs(['--mind', 'default']); + const target = resolveRepairTarget(args, { + homeDir: '/Users/example', + }); + + assert.equal(target.mind, 'default'); + assert.equal(target.repoDir, path.join('/Users/example', '.think', 'repo')); +}); + +test('repair args reject ambiguous mind and repo targets', () => { + const args = parseRepairArgs(['--mind', 'claude', '--repo', '/tmp/mind']); + + assert.throws( + () => resolveRepairTarget(args), + /Use either --mind or --repo/ + ); +}); + +test('content anchor normalization preserves blob anchors as blob entries', async () => { + const blobOid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const treeOid = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const frontierOid = 'cccccccccccccccccccccccccccccccccccccccc'; + const objectTypes = new Map([ + [blobOid, 'blob'], + [treeOid, 'tree'], + ]); + + const normalized = await normalizeContentAnchorTreeEntries([ + `040000 tree ${blobOid}\t_content_${blobOid}`, + `040000 tree ${treeOid}\t_content_${treeOid}`, + `100644 blob ${frontierOid}\tfrontier.cbor`, + ], oid => objectTypes.get(oid)); + + assert.deepEqual(normalized, [ + `100644 blob ${blobOid}\t_content_${blobOid}`, + `040000 tree ${treeOid}\t_content_${treeOid}`, + `100644 blob ${frontierOid}\tfrontier.cbor`, + ]); +}); + +test('createBackupRef creates a dated pre-v17 ref without overwriting an existing ref', () => { + const now = new Date('2026-05-12T16:25:00.000Z'); + const firstRef = 'refs/warp/think/checkpoints/pre-v17-upgrade-20260512-162500'; + const existingRefs = new Set([firstRef]); + const updates = []; + const runner = createGitRunner({ + existingRefs, + updates, + }); + + const backupRef = createBackupRef({ + checkpointSha: OLD_CHECKPOINT, + graph: 'think', + now, + repoDir: '/tmp/think-mind', + runner, + }); + + assert.equal(formatBackupTimestamp(now), '20260512-162500'); + assert.equal(backupRef, 'refs/warp/think/checkpoints/pre-v17-upgrade-20260512-162500-02'); + assert.deepEqual(updates, [{ + newValue: OLD_CHECKPOINT, + oldValue: ZERO_OID, + ref: backupRef, + }]); +}); + +test('repairV17Mind dry-run reports needed repair without mutating refs', async () => { + const events = []; + const runner = createGitRunner({ events }); + let dryRunCount = 0; + + const result = await repairV17Mind({ + dryRun: true, + graph: 'think', + help: false, + json: true, + mind: null, + repo: '/tmp/think-mind', + }, { + runner, + runUpgrade: ({ dryRun }) => { + assert.equal(dryRun, true); + dryRunCount += 1; + return upgradeWouldRun(); + }, + materialize: () => { + throw new Error('dry-run must not materialize'); + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.dryRun, true); + assert.equal(result.wouldRepair, true); + assert.equal(result.backupRef, null); + assert.equal(dryRunCount, 1); + assert.ok(!events.includes('backup'), 'dry-run must not create backup refs'); +}); + +test('repairV17Mind backs up before upgrade and verifies the repaired graph', async () => { + const events = []; + const updates = []; + const runner = createGitRunner({ + events, + updates, + }); + let dryRunCount = 0; + + const result = await repairV17Mind({ + dryRun: false, + graph: 'think', + help: false, + json: true, + mind: null, + repo: '/tmp/think-mind', + }, { + now: new Date('2026-05-12T16:25:00.000Z'), + runner, + runCheck: () => ({ + checkpoint: { + sha: FRESH_CHECKPOINT, + }, + status: { + patchesSinceCheckpoint: 0, + }, + }), + runDoctor: () => ({ + findings: [ + { id: 'repo-accessible', status: 'ok' }, + { id: 'hooks-installed', status: 'warn' }, + ], + summary: { + fail: 0, + warn: 1, + }, + }), + runUpgrade: ({ dryRun }) => { + if (dryRun) { + dryRunCount += 1; + return dryRunCount === 1 ? upgradeWouldRun() : upgradeAlreadyCurrent(); + } + + events.push('upgrade'); + return { + checkpointRef: 'refs/warp/think/checkpoints/head', + currentSchema: 5, + graphName: 'think', + previousCheckpointSha: OLD_CHECKPOINT, + previousSchema: 4, + status: 'upgraded', + upgradedCheckpointSha: UPGRADED_CHECKPOINT, + }; + }, + materialize: () => { + events.push('materialize'); + return { + checkpoint: FRESH_CHECKPOINT, + graph: 'think', + }; + }, + }); + + const backupRef = 'refs/warp/think/checkpoints/pre-v17-upgrade-20260512-162500'; + + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(result.beforeCheckpoint, OLD_CHECKPOINT); + assert.equal(result.afterCheckpoint, FRESH_CHECKPOINT); + assert.equal(result.backupRef, backupRef); + assert.equal(result.upgrade.before, 'needed'); + assert.equal(result.upgrade.after, 'already-current'); + assert.equal(result.check.patchesSinceCheckpoint, 0); + assert.equal(result.doctor.failures, 0); + assert.equal(result.doctor.warnings.length, 1); + assert.deepEqual(updates, [{ + newValue: OLD_CHECKPOINT, + oldValue: ZERO_OID, + ref: backupRef, + }]); + assert.deepEqual(events, ['backup', 'upgrade', 'materialize']); +}); + +function upgradeWouldRun() { + return { + checkpointRef: 'refs/warp/think/checkpoints/head', + currentSchema: 5, + graphName: 'think', + previousCheckpointSha: OLD_CHECKPOINT, + previousSchema: 4, + status: 'would-upgrade', + upgradedCheckpointSha: null, + }; +} + +function upgradeAlreadyCurrent() { + return { + checkpointRef: 'refs/warp/think/checkpoints/head', + currentSchema: 5, + graphName: 'think', + previousCheckpointSha: FRESH_CHECKPOINT, + previousSchema: 5, + status: 'already-current', + upgradedCheckpointSha: FRESH_CHECKPOINT, + }; +} + +function createGitRunner({ + events = [], + existingRefs = new Set(), + updates = [], +} = {}) { + return (_command, args, _options = {}) => { + const gitArgs = args.slice(2); + const subcommand = gitArgs[0]; + + if (subcommand === 'rev-parse' && gitArgs.includes('--git-dir')) { + return commandResult({ stdout: '.git\n' }); + } + + if (subcommand === 'rev-parse' && gitArgs.includes('--verify')) { + return commandResult({ stdout: `${OLD_CHECKPOINT}\n` }); + } + + if (subcommand === 'show-ref') { + const ref = gitArgs.at(-1); + return commandResult({ + status: existingRefs.has(ref) ? 0 : 1, + }); + } + + if (subcommand === 'update-ref') { + const ref = gitArgs[2]; + const newValue = gitArgs[3]; + const oldValue = gitArgs[4]; + events.push('backup'); + updates.push({ newValue, oldValue, ref }); + existingRefs.add(ref); + return commandResult(); + } + + if (subcommand === 'cat-file') { + return commandResult({ stdout: 'blob\n' }); + } + + throw new Error(`Unexpected git command: ${gitArgs.join(' ')}`); + }; +} + +function commandResult({ + status = 0, + stdout = '', + stderr = '', +} = {}) { + return Object.freeze({ + args: [], + command: 'git', + status, + stderr, + stdout, + }); +}