diff --git a/packages/gittensory-miner/lib/replay-snapshot.d.ts b/packages/gittensory-miner/lib/replay-snapshot.d.ts new file mode 100644 index 000000000..d7ee921bc --- /dev/null +++ b/packages/gittensory-miner/lib/replay-snapshot.d.ts @@ -0,0 +1,41 @@ +import type { WorktreeExecFn, WorktreeRemoveResult } from "@jsonbored/gittensory-engine"; + +export const REPLAY_SNAPSHOT_SUBDIR: ".gittensory-replay-snapshots"; + +export type ReplaySnapshotCommit = { sha: string; date: string; subject: string }; +export type ReplaySnapshotTag = { name: string; date: string; targetSha: string }; +export type ReplaySnapshotReadme = { filename: string; content: string }; + +export type ReplaySnapshot = { + repoFullName: string; + commitSha: string; + worktreePath: string; + targetDate: string; + commits: ReplaySnapshotCommit[]; + tags: ReplaySnapshotTag[]; + readme: ReplaySnapshotReadme | null; + exportedAt: string; +}; + +export function resolveReplaySnapshotDbPath(env?: NodeJS.ProcessEnv): string; + +export function planReplaySnapshotPath(input: { repoPath: string; commitSha: string }): string; + +export function validateSnapshotFreshness(input: { targetDate: string; commits: ReplaySnapshotCommit[]; tags: ReplaySnapshotTag[] }): void; + +export type ReplaySnapshotStore = { + dbPath: string; + getSnapshot(repoFullName: string, commitSha: string): ReplaySnapshot | null; + saveSnapshot(snapshot: Omit): ReplaySnapshot; + close(): void; +}; + +export function openReplaySnapshotStore(dbPath?: string): ReplaySnapshotStore; +export function closeDefaultReplaySnapshotStore(): void; + +export function exportReplaySnapshot( + input: { repoPath: string; repoFullName: string; commitSha: string }, + deps: { exec: WorktreeExecFn; store?: ReplaySnapshotStore }, +): Promise; + +export function removeReplaySnapshotWorktree(exec: WorktreeExecFn, repoPath: string, worktreePath: string): Promise; diff --git a/packages/gittensory-miner/lib/replay-snapshot.js b/packages/gittensory-miner/lib/replay-snapshot.js new file mode 100644 index 000000000..b589ddc98 --- /dev/null +++ b/packages/gittensory-miner/lib/replay-snapshot.js @@ -0,0 +1,288 @@ +import { join } from "node:path"; +import { removeWorktree } from "@jsonbored/gittensory-engine"; +import { openLocalStoreDb, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js"; + +// Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports: +// (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive +// worktree-allocator.ts (#4269) uses for attempt isolation, just detached rather than on a new branch, +// since a replay target is read-only, never a place to commit -- so it never mutates the caller's own +// checkout/branch. +// (b) a context bundle: commit history up to and including T (by ANCESTRY, via `git log T` -- walking the DAG +// is the tamper-resistant way to bound "up to T", since a commit's committer date is user-controlled and +// can't be trusted alone), tags reachable from T (`git tag --merged T`), and the README as it existed at +// T (`git ls-tree` + `git show T:`, matched case-insensitively rather than a guessed filename list). +// +// REUSE NOTE: this issue's own text frames "the discover and analyze phases... already read git history" as +// the reuse starting point. Grepped both packages (git log/git tag/commits/tags/releases) before writing this +// and found no such utility anywhere -- opportunity-fanout.js reads GitHub API issue `updated_at`, not git +// commit/tag history at all. The one genuinely reusable piece is worktree-allocator.ts's injected-exec +// convention (WorktreeExecFn) and its removeWorktree -- both reused directly below (import from +// @jsonbored/gittensory-engine), rather than inventing a THIRD "inject the git subprocess" abstraction +// alongside cli-subprocess-driver.ts's and worktree-allocator.ts's own. +// +// FAIL-FAST VALIDATION: ancestry-walking (git log T) already excludes anything NOT reachable from T by +// construction, but a tag can point at a commit that IS an ancestor of T while the TAG's own creation/tagger +// date is LATER (e.g. a tag added long after the commit it points to), and commit committer-dates are not +// strictly monotonic along the DAG in general (rebases, clock skew). So checking every exported commit's date +// and every exported tag's date against T's own commit date is a genuine, not merely defensive, check. +// +// PERSISTENCE: the context bundle is cached in the local store, UNIQUE-keyed on (repo_full_name, commit_sha) -- +// re-exporting the same (repo, T) pair returns the identical cached row rather than re-running git, which is +// both how "byte-reproducible" holds trivially and avoids redundant work on repeat replay runs. The working- +// tree export itself is git-content-addressed already (the same commit SHA always checks out identical files). + +const defaultDbFileName = "replay-snapshot.sqlite3"; +let defaultDb = null; + +export function resolveReplaySnapshotDbPath(env = process.env) { + return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_REPLAY_SNAPSHOT_DB", env); +} + +function normalizeDbPath(dbPath) { + return normalizeLocalStoreDbPath(dbPath, resolveReplaySnapshotDbPath(), "invalid_replay_snapshot_db_path"); +} + +const FIELD_SEP = "\x1f"; +const README_NAME_PATTERN = /^readme(\.\w+)?$/i; + +function normalizeRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + return `${owner}/${repo}`; +} + +function normalizeCommitSha(commitSha) { + if (typeof commitSha !== "string" || !commitSha.trim()) throw new Error("invalid_commit_sha"); + return commitSha.trim(); +} + +/** Worktree exports live under this dir inside the repo, mirroring worktree-allocator.ts's WORKTREE_SUBDIR. */ +export const REPLAY_SNAPSHOT_SUBDIR = ".gittensory-replay-snapshots"; + +/** PURE: the deterministic on-disk location for a (repo, commit) replay export -- same pair -> same path. */ +export function planReplaySnapshotPath(input) { + const commitSha = normalizeCommitSha(input.commitSha); + return join(input.repoPath, REPLAY_SNAPSHOT_SUBDIR, commitSha); +} + +function assertExecResult(result, description) { + if (result.code !== 0) { + const detail = (result.stderr ?? "").trim() || `exit_${result.code}`; + throw new Error(`${description}: ${detail}`); + } + return result.stdout ?? ""; +} + +/** Detached checkout at commitSha via `git worktree add --detach` -- never creates a branch, never touches the + * caller's own checkout. Idempotent in effect: `git worktree add` itself fails if the path already has a + * worktree, which callers avoid by checking the store cache first (see exportReplaySnapshot). */ +async function addDetachedWorktree(exec, repoPath, worktreePath, commitSha) { + const result = await exec("git", ["worktree", "add", "--detach", worktreePath, commitSha], { cwd: repoPath }); + assertExecResult(result, "git_worktree_add_failed"); +} + +async function readTargetCommitDate(exec, repoPath, commitSha) { + const result = await exec("git", ["log", "-1", "--format=%cI", commitSha], { cwd: repoPath }); + const stdout = assertExecResult(result, "git_log_target_failed").trim(); + if (!stdout) throw new Error(`git_log_target_failed: no commit found for ${commitSha}`); + return stdout; +} + +async function readCommitHistory(exec, repoPath, commitSha) { + const result = await exec("git", ["log", commitSha, `--format=%H${FIELD_SEP}%cI${FIELD_SEP}%s`], { cwd: repoPath }); + const stdout = assertExecResult(result, "git_log_history_failed"); + return stdout + .split("\n") + .filter((line) => line.length > 0) + .map((line) => { + const [sha, date, subject] = line.split(FIELD_SEP); + return { sha, date, subject: subject ?? "" }; + }); +} + +// Lightweight tags have no tag object of their own, so `%(creatordate)` falls back to the POINTED-TO commit's +// date rather than a genuine tag-creation date -- git has no record of when a lightweight tag was actually +// created at all. That means a lightweight tag added long after T, but pointing at an ancestor of T, would +// silently pass validateSnapshotFreshness's date check every time (its reported "date" is always <= T's, by +// construction of --merged). Since this can never be verified, lightweight tags are excluded from the export +// entirely -- `%(objecttype)` is "tag" only for an annotated tag's own tag object, "commit" for a lightweight +// tag's direct target, which is how the two are told apart. +async function readReachableTags(exec, repoPath, commitSha) { + const result = await exec( + "git", + ["tag", "--merged", commitSha, `--format=%(refname:short)${FIELD_SEP}%(creatordate:iso-strict)${FIELD_SEP}%(objectname)${FIELD_SEP}%(objecttype)`], + { cwd: repoPath }, + ); + const stdout = assertExecResult(result, "git_tag_merged_failed"); + return stdout + .split("\n") + .filter((line) => line.length > 0) + .map((line) => { + const [name, date, targetSha, objectType] = line.split(FIELD_SEP); + return { name, date, targetSha, objectType }; + }) + .filter((tag) => tag.objectType === "tag") + .map(({ objectType, ...tag }) => tag); +} + +/** Finds the repo-root README (any casing/extension) at commitSha and returns its content, or null if none + * exists at that commit. Uses `git ls-tree` to find the real filename rather than guessing a fixed spelling + * list. */ +async function readReadmeAtCommit(exec, repoPath, commitSha) { + const listing = await exec("git", ["ls-tree", "--name-only", commitSha], { cwd: repoPath }); + const stdout = assertExecResult(listing, "git_ls_tree_failed"); + const filename = stdout + .split("\n") + .map((line) => line.trim()) + .find((line) => README_NAME_PATTERN.test(line)); + if (!filename) return null; + + const shown = await exec("git", ["show", `${commitSha}:${filename}`], { cwd: repoPath }); + const content = assertExecResult(shown, "git_show_readme_failed"); + return { filename, content }; +} + +/** PURE: fails fast (throws) if any exported commit or tag carries a date LATER than the target commit's own + * date. Returns nothing on success. */ +export function validateSnapshotFreshness(input) { + const targetMs = Date.parse(input.targetDate); + const violations = []; + for (const commit of input.commits) { + if (Date.parse(commit.date) > targetMs) violations.push(`commit ${commit.sha} dated ${commit.date} is after target ${input.targetDate}`); + } + for (const tag of input.tags) { + if (Date.parse(tag.date) > targetMs) violations.push(`tag ${tag.name} dated ${tag.date} is after target ${input.targetDate}`); + } + if (violations.length > 0) throw new Error(`replay_snapshot_freshness_violation: ${violations.join("; ")}`); +} + +export function openReplaySnapshotStore(dbPath = resolveReplaySnapshotDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS replay_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_full_name TEXT NOT NULL, + commit_sha TEXT NOT NULL, + worktree_path TEXT NOT NULL, + target_date TEXT NOT NULL, + commits_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + readme_filename TEXT, + readme_content TEXT, + exported_at TEXT NOT NULL, + UNIQUE (repo_full_name, commit_sha) + ) + `); + const getStatement = db.prepare("SELECT * FROM replay_snapshots WHERE repo_full_name = ? AND commit_sha = ?"); + const insertStatement = db.prepare(` + INSERT INTO replay_snapshots + (repo_full_name, commit_sha, worktree_path, target_date, commits_json, tags_json, readme_filename, readme_content, exported_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + function rowToSnapshot(row) { + return { + repoFullName: row.repo_full_name, + commitSha: row.commit_sha, + worktreePath: row.worktree_path, + targetDate: row.target_date, + commits: JSON.parse(row.commits_json), + tags: JSON.parse(row.tags_json), + readme: row.readme_filename ? { filename: row.readme_filename, content: row.readme_content } : null, + exportedAt: row.exported_at, + }; + } + + return { + dbPath: resolvedPath, + getSnapshot(repoFullName, commitSha) { + const row = getStatement.get(normalizeRepoFullName(repoFullName), normalizeCommitSha(commitSha)); + return row ? rowToSnapshot(row) : null; + }, + saveSnapshot(snapshot) { + const repoFullName = normalizeRepoFullName(snapshot.repoFullName); + const commitSha = normalizeCommitSha(snapshot.commitSha); + insertStatement.run( + repoFullName, + commitSha, + snapshot.worktreePath, + snapshot.targetDate, + JSON.stringify(snapshot.commits), + JSON.stringify(snapshot.tags), + snapshot.readme?.filename ?? null, + snapshot.readme?.content ?? null, + new Date().toISOString(), + ); + return this.getSnapshot(repoFullName, commitSha); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultReplaySnapshotStore() { + defaultDb ??= openReplaySnapshotStore(); + return defaultDb; +} + +export function closeDefaultReplaySnapshotStore() { + if (!defaultDb) return; + defaultDb.close(); + defaultDb = null; +} + +/** + * Export a frozen, reproducible replay snapshot for (repoFullName, commitSha): a detached working-tree checkout + * at that commit plus a context bundle (commit history, reachable tags, README-at-commit). Returns the CACHED + * snapshot without touching git again if one already exists for this exact (repo, commit) pair. + * + * @param {{ repoPath: string, repoFullName: string, commitSha: string }} input + * @param {{ exec: import("./worktree-allocator.js").WorktreeExecFn, store?: ReturnType }} deps + */ +export async function exportReplaySnapshot(input, deps) { + if (!input || typeof input !== "object") throw new Error("invalid_replay_snapshot_input"); + const repoFullName = normalizeRepoFullName(input.repoFullName); + const commitSha = normalizeCommitSha(input.commitSha); + if (typeof input.repoPath !== "string" || !input.repoPath.trim()) throw new Error("invalid_repo_path"); + const repoPath = input.repoPath.trim(); + + if (!deps || typeof deps !== "object" || typeof deps.exec !== "function") throw new Error("invalid_exec"); + const { exec } = deps; + const store = deps.store ?? getDefaultReplaySnapshotStore(); + + const cached = store.getSnapshot(repoFullName, commitSha); + if (cached) return cached; + + const worktreePath = planReplaySnapshotPath({ repoPath, commitSha }); + await addDetachedWorktree(exec, repoPath, worktreePath, commitSha); + + // Everything below can fail (a bad git read, or a deliberate freshness violation) after the worktree already + // exists on disk at the deterministic path above. Left behind, a retry for the same (repo, commit) pair would + // hit `git worktree add`'s own "path already exists" refusal instead of the real error, permanently masking + // it. Clean up the worktree on any failure here before rethrowing, so a retry starts from a clean slate. + try { + const targetDate = await readTargetCommitDate(exec, repoPath, commitSha); + const commits = await readCommitHistory(exec, repoPath, commitSha); + const tags = await readReachableTags(exec, repoPath, commitSha); + const readme = await readReadmeAtCommit(exec, repoPath, commitSha); + + validateSnapshotFreshness({ targetDate, commits, tags }); + + return store.saveSnapshot({ repoFullName, commitSha, worktreePath, targetDate, commits, tags, readme }); + } catch (error) { + await removeReplaySnapshotWorktree(exec, repoPath, worktreePath).catch(() => { + /* best-effort cleanup -- the original error below is the one that matters to the caller */ + }); + throw error; + } +} + +/** Tear down a replay snapshot's working-tree export (the cached context-bundle row is left in place -- it is + * cheap, commit-keyed, and re-usable even after the on-disk tree is removed; only re-adding the worktree would + * require the tree again, which is out of this function's scope). */ +export async function removeReplaySnapshotWorktree(exec, repoPath, worktreePath) { + return removeWorktree({ exec, repoPath, worktreePath }); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 1ba07e4d9..46e9962cf 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-replay-snapshot.test.ts b/test/unit/miner-replay-snapshot.test.ts new file mode 100644 index 000000000..b7a842fe1 --- /dev/null +++ b/test/unit/miner-replay-snapshot.test.ts @@ -0,0 +1,440 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + closeDefaultReplaySnapshotStore, + exportReplaySnapshot, + openReplaySnapshotStore, + planReplaySnapshotPath, + removeReplaySnapshotWorktree, + REPLAY_SNAPSHOT_SUBDIR, + validateSnapshotFreshness, +} from "../../packages/gittensory-miner/lib/replay-snapshot.js"; + +const FIELD_SEP = "\x1f"; + +type ExecResult = { code: number | null; stdout?: string; stderr?: string }; +type ExecCall = { cmd: string; args: readonly string[]; cwd: string }; + +function scriptedExec(scripts: Array<{ match: (args: readonly string[]) => boolean; result: ExecResult }>) { + const calls: ExecCall[] = []; + const exec = async (cmd: string, args: readonly string[], opts: { cwd: string }): Promise => { + calls.push({ cmd, args, cwd: opts.cwd }); + const script = scripts.find((s) => s.match(args)); + if (!script) throw new Error(`no script matched: ${args.join(" ")}`); + return script.result; + }; + return { exec, calls }; +} + +const isWorktreeAdd = (args: readonly string[]) => args[0] === "worktree" && args[1] === "add"; +const isTargetDate = (args: readonly string[]) => args[0] === "log" && args[1] === "-1"; +const isHistory = (args: readonly string[]) => args[0] === "log" && args[1] !== "-1"; +const isTag = (args: readonly string[]) => args[0] === "tag"; +const isLsTree = (args: readonly string[]) => args[0] === "ls-tree"; +const isShow = (args: readonly string[]) => args[0] === "show"; + +function ok(stdout = ""): ExecResult { + return { code: 0, stdout }; +} + +/** A realistic happy-path script set: 1 commit history entry, 1 tag, a found README -- callers override + * individual entries (by unshifting a higher-priority match) for other scenarios. */ +function happyPathScripts(overrides: Array<{ match: (args: readonly string[]) => boolean; result: ExecResult }> = []) { + return [ + ...overrides, + { match: isWorktreeAdd, result: ok() }, + { match: isTargetDate, result: ok("2026-01-05T00:00:00+00:00\n") }, + { match: isHistory, result: ok(`abc123${FIELD_SEP}2026-01-05T00:00:00+00:00${FIELD_SEP}the target commit\n`) }, + { match: isTag, result: ok(`v1.0.0${FIELD_SEP}2026-01-01T00:00:00+00:00${FIELD_SEP}abc000${FIELD_SEP}tag\n`) }, + { match: isLsTree, result: ok("README.md\nsrc\npackage.json\n") }, + { match: isShow, result: ok("# hello\n") }, + ]; +} + +let stores: Array<{ close(): void }> = []; +let roots: string[] = []; +function tempStore() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-replay-snapshot-")); + roots.push(root); + const store = openReplaySnapshotStore(join(root, "db.sqlite3")); + stores.push(store); + return store; +} + +afterEach(() => { + closeDefaultReplaySnapshotStore(); + for (const s of stores.splice(0)) s.close(); + for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true }); +}); + +describe("planReplaySnapshotPath (#3010) — pure, deterministic", () => { + it("same (repoPath, commitSha) always yields the same path", () => { + const a = planReplaySnapshotPath({ repoPath: "/repo", commitSha: "abc123" }); + expect(a.replaceAll("\\", "/")).toBe(`/repo/${REPLAY_SNAPSHOT_SUBDIR}/abc123`); + expect(planReplaySnapshotPath({ repoPath: "/repo", commitSha: "abc123" })).toBe(a); + expect(planReplaySnapshotPath({ repoPath: "/repo", commitSha: "def456" })).not.toBe(a); + }); +}); + +describe("validateSnapshotFreshness (#3010) — pure fail-fast check", () => { + const targetDate = "2026-01-05T00:00:00+00:00"; + + it("passes when every commit and tag date is at or before the target", () => { + expect(() => + validateSnapshotFreshness({ + targetDate, + commits: [{ sha: "a", date: targetDate, subject: "t" }, { sha: "b", date: "2026-01-01T00:00:00Z", subject: "s" }], + tags: [{ name: "v1", date: "2025-12-01T00:00:00Z", targetSha: "b" }], + }), + ).not.toThrow(); + }); + + it("throws, listing ALL violations, when a commit is dated after the target", () => { + expect(() => + validateSnapshotFreshness({ + targetDate, + commits: [{ sha: "future1", date: "2026-02-01T00:00:00Z", subject: "s" }], + tags: [], + }), + ).toThrow(/future1 dated 2026-02-01T00:00:00Z is after target/); + }); + + it("throws when a tag is dated after the target, independent of commit dates", () => { + expect(() => + validateSnapshotFreshness({ + targetDate, + commits: [{ sha: "a", date: targetDate, subject: "t" }], + tags: [{ name: "v-future", date: "2026-03-01T00:00:00Z", targetSha: "a" }], + }), + ).toThrow(/v-future dated 2026-03-01T00:00:00Z is after target/); + }); + + it("lists both a commit AND a tag violation together in one error", () => { + expect(() => + validateSnapshotFreshness({ + targetDate, + commits: [{ sha: "future1", date: "2026-02-01T00:00:00Z", subject: "s" }], + tags: [{ name: "v-future", date: "2026-03-01T00:00:00Z", targetSha: "future1" }], + }), + ).toThrow(/future1.*v-future|v-future.*future1/s); + }); +}); + +describe("exportReplaySnapshot (#3010)", () => { + it("exports a fresh snapshot: worktree, target date, commit history, reachable tags, and README", async () => { + const { exec } = scriptedExec(happyPathScripts()); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot( + { repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, + { exec, store }, + ); + + expect(snapshot.repoFullName).toBe("acme/widgets"); + expect(snapshot.commitSha).toBe("abc123"); + expect(snapshot.targetDate).toBe("2026-01-05T00:00:00+00:00"); + expect(snapshot.commits).toEqual([{ sha: "abc123", date: "2026-01-05T00:00:00+00:00", subject: "the target commit" }]); + expect(snapshot.tags).toEqual([{ name: "v1.0.0", date: "2026-01-01T00:00:00+00:00", targetSha: "abc000" }]); + expect(snapshot.readme).toEqual({ filename: "README.md", content: "# hello\n" }); + }); + + it("returns the cached snapshot on a repeat export of the same (repo, commit) pair, without calling git again", async () => { + const store = tempStore(); + const first = scriptedExec(happyPathScripts()); + await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec: first.exec, store }); + + const second = scriptedExec([]); // no scripts at all -- any call would throw "no script matched" + const result = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec: second.exec, store }); + + expect(result.commits).toHaveLength(1); + expect(second.calls).toHaveLength(0); + }); + + it("a repo with NO tags yields an empty tags array", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isTag, result: ok("") }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.tags).toEqual([]); + }); + + it("a repo with MULTIPLE annotated tags parses every reachable tag", async () => { + const tagStdout = [ + `v1.0.0${FIELD_SEP}2025-12-01T00:00:00+00:00${FIELD_SEP}sha1${FIELD_SEP}tag`, + `v1.1.0${FIELD_SEP}2026-01-01T00:00:00+00:00${FIELD_SEP}sha2${FIELD_SEP}tag`, + ].join("\n") + "\n"; + const { exec } = scriptedExec(happyPathScripts([{ match: isTag, result: ok(tagStdout) }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.tags).toEqual([ + { name: "v1.0.0", date: "2025-12-01T00:00:00+00:00", targetSha: "sha1" }, + { name: "v1.1.0", date: "2026-01-01T00:00:00+00:00", targetSha: "sha2" }, + ]); + }); + + it("excludes a lightweight tag from the export -- its reported date is the pointed-to commit's, not a verifiable tag-creation date", async () => { + const tagStdout = [ + `v1.0.0${FIELD_SEP}2025-12-01T00:00:00+00:00${FIELD_SEP}sha1${FIELD_SEP}tag`, // annotated -- kept + `v-lightweight${FIELD_SEP}2026-01-01T00:00:00+00:00${FIELD_SEP}sha2${FIELD_SEP}commit`, // lightweight -- dropped + ].join("\n") + "\n"; + const { exec } = scriptedExec(happyPathScripts([{ match: isTag, result: ok(tagStdout) }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.tags).toEqual([{ name: "v1.0.0", date: "2025-12-01T00:00:00+00:00", targetSha: "sha1" }]); + }); + + it("a commit at the very first commit of history: git log returns exactly one entry, no parents to walk", async () => { + const { exec } = scriptedExec( + happyPathScripts([ + { match: isHistory, result: ok(`root000${FIELD_SEP}2020-01-01T00:00:00+00:00${FIELD_SEP}initial commit\n`) }, + { match: isTargetDate, result: ok("2020-01-01T00:00:00+00:00\n") }, + { match: isTag, result: ok("") }, // no tag can predate the very first commit + ]), + ); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "root000" }, { exec, store }); + + expect(snapshot.commits).toEqual([{ sha: "root000", date: "2020-01-01T00:00:00+00:00", subject: "initial commit" }]); + }); + + it("no README present at the commit: readme is null, and show is never called for a nonexistent file", async () => { + const { exec, calls } = scriptedExec(happyPathScripts([{ match: isLsTree, result: ok("src\npackage.json\n") }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.readme).toBeNull(); + expect(calls.some((c) => c.args[0] === "show")).toBe(false); + }); + + it("matches a differently-cased/spelled README (case-insensitive, any extension)", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isLsTree, result: ok("src\nReadme.rst\npackage.json\n") }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.readme?.filename).toBe("Readme.rst"); + }); + + it("throws a freshness violation, never persists, AND removes the worktree it already created -- a retry for the same pair must not hit a stale 'path already exists'", async () => { + const { exec, calls } = scriptedExec(happyPathScripts([{ match: isTag, result: ok(`v-future${FIELD_SEP}2026-06-01T00:00:00+00:00${FIELD_SEP}abc000${FIELD_SEP}tag\n`) }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /replay_snapshot_freshness_violation/, + ); + expect(store.getSnapshot("acme/widgets", "abc123")).toBeNull(); + const removeCall = calls.find((c) => c.args[0] === "worktree" && c.args[1] === "remove"); + expect(removeCall?.args).toEqual(["worktree", "remove", "--force", "/repo/.gittensory-replay-snapshots/abc123"]); + }); + + it("removes the worktree and rethrows the ORIGINAL error (not a cleanup error) when a git read after the worktree exists fails", async () => { + const { exec, calls } = scriptedExec(happyPathScripts([{ match: isHistory, result: { code: 1, stderr: "fatal: history read failed" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_log_history_failed/, + ); + const removeCall = calls.find((c) => c.args[0] === "worktree" && c.args[1] === "remove"); + expect(removeCall).toBeDefined(); + }); + + it("still rethrows the original error even when the best-effort cleanup itself fails", async () => { + const { exec } = scriptedExec( + happyPathScripts([ + { match: isHistory, result: { code: 1, stderr: "fatal: history read failed" } }, + { match: (args) => args[0] === "worktree" && args[1] === "remove", result: { code: 1, stderr: "fatal: cleanup also failed" } }, + ]), + ); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_log_history_failed/, + ); + }); + + it("tolerates a successful exec result with no stdout captured at all (e.g. worktree add, whose output is unused)", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isWorktreeAdd, result: { code: 0 } }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.commitSha).toBe("abc123"); + }); + + it("throws when git worktree add fails", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isWorktreeAdd, result: { code: 1, stderr: "fatal: invalid reference" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "bogus" }, { exec, store })).rejects.toThrow( + /git_worktree_add_failed.*fatal: invalid reference/, + ); + }); + + it("throws when the target-commit date lookup fails", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isTargetDate, result: { code: 128, stderr: "fatal: bad revision" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "bogus" }, { exec, store })).rejects.toThrow( + /git_log_target_failed/, + ); + }); + + it("throws when the target-commit date lookup returns empty (commit not found)", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isTargetDate, result: ok("") }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "bogus" }, { exec, store })).rejects.toThrow( + /git_log_target_failed: no commit found/, + ); + }); + + it("throws when reading commit history fails", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isHistory, result: { code: 1, stderr: "fatal: history read failed" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_log_history_failed/, + ); + }); + + it("throws when reading reachable tags fails", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isTag, result: { code: 1, stderr: "fatal: tag read failed" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_tag_merged_failed/, + ); + }); + + it("throws when listing the tree for the README search fails", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isLsTree, result: { code: 1, stderr: "fatal: ls-tree failed" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_ls_tree_failed/, + ); + }); + + it("throws when a README was found in the tree listing but reading its content fails", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isShow, result: { code: 1, stderr: "fatal: show failed" } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_show_readme_failed/, + ); + }); + + it("rejects a repoFullName that is a string but not owner/repo shaped (too few or too many segments)", async () => { + const { exec } = scriptedExec(happyPathScripts()); + const store = tempStore(); + const deps = { exec, store }; + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "noslash", commitSha: "a" }, deps)).rejects.toThrow("invalid_repo_full_name"); + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "a/b/c", commitSha: "a" }, deps)).rejects.toThrow("invalid_repo_full_name"); + }); + + it("assertExecResult falls back to a generic exit-code message when stderr is entirely absent", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isWorktreeAdd, result: { code: 1 } }])); + const store = tempStore(); + + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store })).rejects.toThrow( + /git_worktree_add_failed: exit_1/, + ); + }); + + it("tolerates a commit-history line missing the subject field, defaulting it to an empty string", async () => { + const { exec } = scriptedExec(happyPathScripts([{ match: isHistory, result: ok(`abc123${FIELD_SEP}2026-01-05T00:00:00+00:00\n`) }])); + const store = tempStore(); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec, store }); + + expect(snapshot.commits).toEqual([{ sha: "abc123", date: "2026-01-05T00:00:00+00:00", subject: "" }]); + }); + + it("fails closed on a malformed input", async () => { + const { exec } = scriptedExec(happyPathScripts()); + const store = tempStore(); + const deps = { exec, store }; + + await expect(exportReplaySnapshot(null as never, deps)).rejects.toThrow("invalid_replay_snapshot_input"); + await expect(exportReplaySnapshot({ commitSha: "a" } as never, deps)).rejects.toThrow("invalid_repo_full_name"); + await expect(exportReplaySnapshot({ repoFullName: "acme/widgets" } as never, deps)).rejects.toThrow("invalid_commit_sha"); + await expect(exportReplaySnapshot({ repoFullName: "acme/widgets", commitSha: "abc123" } as never, deps)).rejects.toThrow("invalid_repo_path"); + }); + + it("fails closed when exec is missing or invalid", async () => { + const store = tempStore(); + const candidate = { repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }; + await expect(exportReplaySnapshot(candidate, null as never)).rejects.toThrow("invalid_exec"); + await expect(exportReplaySnapshot(candidate, { store } as never)).rejects.toThrow("invalid_exec"); + }); + + it("falls back to the default (env-resolved) store when deps.store is omitted", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-replay-snapshot-default-")); + roots.push(root); + vi.stubEnv("GITTENSORY_MINER_REPLAY_SNAPSHOT_DB", join(root, "default.sqlite3")); + const { exec } = scriptedExec(happyPathScripts()); + + const snapshot = await exportReplaySnapshot({ repoPath: "/repo", repoFullName: "acme/widgets", commitSha: "abc123" }, { exec }); + + expect(snapshot.repoFullName).toBe("acme/widgets"); + vi.unstubAllEnvs(); + }); +}); + +describe("removeReplaySnapshotWorktree (#3010)", () => { + it("delegates to the shared removeWorktree primitive", async () => { + const { exec, calls } = scriptedExec([{ match: () => true, result: ok() }]); + const result = await removeReplaySnapshotWorktree(exec, "/repo", "/repo/.gittensory-replay-snapshots/abc123"); + expect(result).toEqual({ ok: true, removed: true }); + expect(calls[0]?.args).toEqual(["worktree", "remove", "--force", "/repo/.gittensory-replay-snapshots/abc123"]); + }); +}); + +describe("openReplaySnapshotStore (#3010) — round-trip persistence", () => { + it("round-trips a full snapshot including a populated README", () => { + const store = tempStore(); + const saved = store.saveSnapshot({ + repoFullName: "acme/widgets", + commitSha: "abc123", + worktreePath: "/repo/.gittensory-replay-snapshots/abc123", + targetDate: "2026-01-05T00:00:00+00:00", + commits: [{ sha: "abc123", date: "2026-01-05T00:00:00+00:00", subject: "t" }], + tags: [{ name: "v1", date: "2026-01-01T00:00:00+00:00", targetSha: "abc000" }], + readme: { filename: "README.md", content: "# hi\n" }, + }); + + expect(saved.readme).toEqual({ filename: "README.md", content: "# hi\n" }); + expect(store.getSnapshot("acme/widgets", "abc123")).toEqual(saved); + }); + + it("round-trips a snapshot with no README as null, not a partial object", () => { + const store = tempStore(); + const saved = store.saveSnapshot({ + repoFullName: "acme/widgets", + commitSha: "abc123", + worktreePath: "/repo/.gittensory-replay-snapshots/abc123", + targetDate: "2026-01-05T00:00:00+00:00", + commits: [], + tags: [], + readme: null, + }); + + expect(saved.readme).toBeNull(); + }); + + it("getSnapshot returns null for an unknown (repo, commit) pair", () => { + const store = tempStore(); + expect(store.getSnapshot("acme/widgets", "nope")).toBeNull(); + }); +});