|
| 1 | +import { join } from "node:path"; |
| 2 | +import { removeWorktree } from "@jsonbored/gittensory-engine"; |
| 3 | +import { openLocalStoreDb, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js"; |
| 4 | + |
| 5 | +// Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports: |
| 6 | +// (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive |
| 7 | +// worktree-allocator.ts (#4269) uses for attempt isolation, just detached rather than on a new branch, |
| 8 | +// since a replay target is read-only, never a place to commit -- so it never mutates the caller's own |
| 9 | +// checkout/branch. |
| 10 | +// (b) a context bundle: commit history up to and including T (by ANCESTRY, via `git log T` -- walking the DAG |
| 11 | +// is the tamper-resistant way to bound "up to T", since a commit's committer date is user-controlled and |
| 12 | +// can't be trusted alone), tags reachable from T (`git tag --merged T`), and the README as it existed at |
| 13 | +// T (`git ls-tree` + `git show T:<name>`, matched case-insensitively rather than a guessed filename list). |
| 14 | +// |
| 15 | +// REUSE NOTE: this issue's own text frames "the discover and analyze phases... already read git history" as |
| 16 | +// the reuse starting point. Grepped both packages (git log/git tag/commits/tags/releases) before writing this |
| 17 | +// and found no such utility anywhere -- opportunity-fanout.js reads GitHub API issue `updated_at`, not git |
| 18 | +// commit/tag history at all. The one genuinely reusable piece is worktree-allocator.ts's injected-exec |
| 19 | +// convention (WorktreeExecFn) and its removeWorktree -- both reused directly below (import from |
| 20 | +// @jsonbored/gittensory-engine), rather than inventing a THIRD "inject the git subprocess" abstraction |
| 21 | +// alongside cli-subprocess-driver.ts's and worktree-allocator.ts's own. |
| 22 | +// |
| 23 | +// FAIL-FAST VALIDATION: ancestry-walking (git log T) already excludes anything NOT reachable from T by |
| 24 | +// construction, but a tag can point at a commit that IS an ancestor of T while the TAG's own creation/tagger |
| 25 | +// date is LATER (e.g. a tag added long after the commit it points to), and commit committer-dates are not |
| 26 | +// strictly monotonic along the DAG in general (rebases, clock skew). So checking every exported commit's date |
| 27 | +// and every exported tag's date against T's own commit date is a genuine, not merely defensive, check. |
| 28 | +// |
| 29 | +// PERSISTENCE: the context bundle is cached in the local store, UNIQUE-keyed on (repo_full_name, commit_sha) -- |
| 30 | +// re-exporting the same (repo, T) pair returns the identical cached row rather than re-running git, which is |
| 31 | +// both how "byte-reproducible" holds trivially and avoids redundant work on repeat replay runs. The working- |
| 32 | +// tree export itself is git-content-addressed already (the same commit SHA always checks out identical files). |
| 33 | + |
| 34 | +const defaultDbFileName = "replay-snapshot.sqlite3"; |
| 35 | +let defaultDb = null; |
| 36 | + |
| 37 | +export function resolveReplaySnapshotDbPath(env = process.env) { |
| 38 | + return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_REPLAY_SNAPSHOT_DB", env); |
| 39 | +} |
| 40 | + |
| 41 | +function normalizeDbPath(dbPath) { |
| 42 | + return normalizeLocalStoreDbPath(dbPath, resolveReplaySnapshotDbPath(), "invalid_replay_snapshot_db_path"); |
| 43 | +} |
| 44 | + |
| 45 | +const FIELD_SEP = "\x1f"; |
| 46 | +const README_NAME_PATTERN = /^readme(\.\w+)?$/i; |
| 47 | + |
| 48 | +function normalizeRepoFullName(repoFullName) { |
| 49 | + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); |
| 50 | + const [owner, repo, extra] = repoFullName.trim().split("/"); |
| 51 | + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); |
| 52 | + return `${owner}/${repo}`; |
| 53 | +} |
| 54 | + |
| 55 | +function normalizeCommitSha(commitSha) { |
| 56 | + if (typeof commitSha !== "string" || !commitSha.trim()) throw new Error("invalid_commit_sha"); |
| 57 | + return commitSha.trim(); |
| 58 | +} |
| 59 | + |
| 60 | +/** Worktree exports live under this dir inside the repo, mirroring worktree-allocator.ts's WORKTREE_SUBDIR. */ |
| 61 | +export const REPLAY_SNAPSHOT_SUBDIR = ".gittensory-replay-snapshots"; |
| 62 | + |
| 63 | +/** PURE: the deterministic on-disk location for a (repo, commit) replay export -- same pair -> same path. */ |
| 64 | +export function planReplaySnapshotPath(input) { |
| 65 | + const commitSha = normalizeCommitSha(input.commitSha); |
| 66 | + return join(input.repoPath, REPLAY_SNAPSHOT_SUBDIR, commitSha); |
| 67 | +} |
| 68 | + |
| 69 | +function assertExecResult(result, description) { |
| 70 | + if (result.code !== 0) { |
| 71 | + const detail = (result.stderr ?? "").trim() || `exit_${result.code}`; |
| 72 | + throw new Error(`${description}: ${detail}`); |
| 73 | + } |
| 74 | + return result.stdout ?? ""; |
| 75 | +} |
| 76 | + |
| 77 | +/** Detached checkout at commitSha via `git worktree add --detach` -- never creates a branch, never touches the |
| 78 | + * caller's own checkout. Idempotent in effect: `git worktree add` itself fails if the path already has a |
| 79 | + * worktree, which callers avoid by checking the store cache first (see exportReplaySnapshot). */ |
| 80 | +async function addDetachedWorktree(exec, repoPath, worktreePath, commitSha) { |
| 81 | + const result = await exec("git", ["worktree", "add", "--detach", worktreePath, commitSha], { cwd: repoPath }); |
| 82 | + assertExecResult(result, "git_worktree_add_failed"); |
| 83 | +} |
| 84 | + |
| 85 | +async function readTargetCommitDate(exec, repoPath, commitSha) { |
| 86 | + const result = await exec("git", ["log", "-1", "--format=%cI", commitSha], { cwd: repoPath }); |
| 87 | + const stdout = assertExecResult(result, "git_log_target_failed").trim(); |
| 88 | + if (!stdout) throw new Error(`git_log_target_failed: no commit found for ${commitSha}`); |
| 89 | + return stdout; |
| 90 | +} |
| 91 | + |
| 92 | +async function readCommitHistory(exec, repoPath, commitSha) { |
| 93 | + const result = await exec("git", ["log", commitSha, `--format=%H${FIELD_SEP}%cI${FIELD_SEP}%s`], { cwd: repoPath }); |
| 94 | + const stdout = assertExecResult(result, "git_log_history_failed"); |
| 95 | + return stdout |
| 96 | + .split("\n") |
| 97 | + .filter((line) => line.length > 0) |
| 98 | + .map((line) => { |
| 99 | + const [sha, date, subject] = line.split(FIELD_SEP); |
| 100 | + return { sha, date, subject: subject ?? "" }; |
| 101 | + }); |
| 102 | +} |
| 103 | + |
| 104 | +async function readReachableTags(exec, repoPath, commitSha) { |
| 105 | + const result = await exec( |
| 106 | + "git", |
| 107 | + ["tag", "--merged", commitSha, `--format=%(refname:short)${FIELD_SEP}%(creatordate:iso-strict)${FIELD_SEP}%(objectname)`], |
| 108 | + { cwd: repoPath }, |
| 109 | + ); |
| 110 | + const stdout = assertExecResult(result, "git_tag_merged_failed"); |
| 111 | + return stdout |
| 112 | + .split("\n") |
| 113 | + .filter((line) => line.length > 0) |
| 114 | + .map((line) => { |
| 115 | + const [name, date, targetSha] = line.split(FIELD_SEP); |
| 116 | + return { name, date, targetSha }; |
| 117 | + }); |
| 118 | +} |
| 119 | + |
| 120 | +/** Finds the repo-root README (any casing/extension) at commitSha and returns its content, or null if none |
| 121 | + * exists at that commit. Uses `git ls-tree` to find the real filename rather than guessing a fixed spelling |
| 122 | + * list. */ |
| 123 | +async function readReadmeAtCommit(exec, repoPath, commitSha) { |
| 124 | + const listing = await exec("git", ["ls-tree", "--name-only", commitSha], { cwd: repoPath }); |
| 125 | + const stdout = assertExecResult(listing, "git_ls_tree_failed"); |
| 126 | + const filename = stdout |
| 127 | + .split("\n") |
| 128 | + .map((line) => line.trim()) |
| 129 | + .find((line) => README_NAME_PATTERN.test(line)); |
| 130 | + if (!filename) return null; |
| 131 | + |
| 132 | + const shown = await exec("git", ["show", `${commitSha}:${filename}`], { cwd: repoPath }); |
| 133 | + const content = assertExecResult(shown, "git_show_readme_failed"); |
| 134 | + return { filename, content }; |
| 135 | +} |
| 136 | + |
| 137 | +/** PURE: fails fast (throws) if any exported commit or tag carries a date LATER than the target commit's own |
| 138 | + * date. Returns nothing on success. */ |
| 139 | +export function validateSnapshotFreshness(input) { |
| 140 | + const targetMs = Date.parse(input.targetDate); |
| 141 | + const violations = []; |
| 142 | + for (const commit of input.commits) { |
| 143 | + if (Date.parse(commit.date) > targetMs) violations.push(`commit ${commit.sha} dated ${commit.date} is after target ${input.targetDate}`); |
| 144 | + } |
| 145 | + for (const tag of input.tags) { |
| 146 | + if (Date.parse(tag.date) > targetMs) violations.push(`tag ${tag.name} dated ${tag.date} is after target ${input.targetDate}`); |
| 147 | + } |
| 148 | + if (violations.length > 0) throw new Error(`replay_snapshot_freshness_violation: ${violations.join("; ")}`); |
| 149 | +} |
| 150 | + |
| 151 | +export function openReplaySnapshotStore(dbPath = resolveReplaySnapshotDbPath()) { |
| 152 | + const resolvedPath = normalizeDbPath(dbPath); |
| 153 | + const db = openLocalStoreDb(resolvedPath); |
| 154 | + db.exec(` |
| 155 | + CREATE TABLE IF NOT EXISTS replay_snapshots ( |
| 156 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 157 | + repo_full_name TEXT NOT NULL, |
| 158 | + commit_sha TEXT NOT NULL, |
| 159 | + worktree_path TEXT NOT NULL, |
| 160 | + target_date TEXT NOT NULL, |
| 161 | + commits_json TEXT NOT NULL, |
| 162 | + tags_json TEXT NOT NULL, |
| 163 | + readme_filename TEXT, |
| 164 | + readme_content TEXT, |
| 165 | + exported_at TEXT NOT NULL, |
| 166 | + UNIQUE (repo_full_name, commit_sha) |
| 167 | + ) |
| 168 | + `); |
| 169 | + const getStatement = db.prepare("SELECT * FROM replay_snapshots WHERE repo_full_name = ? AND commit_sha = ?"); |
| 170 | + const insertStatement = db.prepare(` |
| 171 | + INSERT INTO replay_snapshots |
| 172 | + (repo_full_name, commit_sha, worktree_path, target_date, commits_json, tags_json, readme_filename, readme_content, exported_at) |
| 173 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 174 | + `); |
| 175 | + |
| 176 | + function rowToSnapshot(row) { |
| 177 | + return { |
| 178 | + repoFullName: row.repo_full_name, |
| 179 | + commitSha: row.commit_sha, |
| 180 | + worktreePath: row.worktree_path, |
| 181 | + targetDate: row.target_date, |
| 182 | + commits: JSON.parse(row.commits_json), |
| 183 | + tags: JSON.parse(row.tags_json), |
| 184 | + readme: row.readme_filename ? { filename: row.readme_filename, content: row.readme_content } : null, |
| 185 | + exportedAt: row.exported_at, |
| 186 | + }; |
| 187 | + } |
| 188 | + |
| 189 | + return { |
| 190 | + dbPath: resolvedPath, |
| 191 | + getSnapshot(repoFullName, commitSha) { |
| 192 | + const row = getStatement.get(normalizeRepoFullName(repoFullName), normalizeCommitSha(commitSha)); |
| 193 | + return row ? rowToSnapshot(row) : null; |
| 194 | + }, |
| 195 | + saveSnapshot(snapshot) { |
| 196 | + const repoFullName = normalizeRepoFullName(snapshot.repoFullName); |
| 197 | + const commitSha = normalizeCommitSha(snapshot.commitSha); |
| 198 | + insertStatement.run( |
| 199 | + repoFullName, |
| 200 | + commitSha, |
| 201 | + snapshot.worktreePath, |
| 202 | + snapshot.targetDate, |
| 203 | + JSON.stringify(snapshot.commits), |
| 204 | + JSON.stringify(snapshot.tags), |
| 205 | + snapshot.readme?.filename ?? null, |
| 206 | + snapshot.readme?.content ?? null, |
| 207 | + new Date().toISOString(), |
| 208 | + ); |
| 209 | + return this.getSnapshot(repoFullName, commitSha); |
| 210 | + }, |
| 211 | + close() { |
| 212 | + db.close(); |
| 213 | + }, |
| 214 | + }; |
| 215 | +} |
| 216 | + |
| 217 | +function getDefaultReplaySnapshotStore() { |
| 218 | + defaultDb ??= openReplaySnapshotStore(); |
| 219 | + return defaultDb; |
| 220 | +} |
| 221 | + |
| 222 | +export function closeDefaultReplaySnapshotStore() { |
| 223 | + if (!defaultDb) return; |
| 224 | + defaultDb.close(); |
| 225 | + defaultDb = null; |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Export a frozen, reproducible replay snapshot for (repoFullName, commitSha): a detached working-tree checkout |
| 230 | + * at that commit plus a context bundle (commit history, reachable tags, README-at-commit). Returns the CACHED |
| 231 | + * snapshot without touching git again if one already exists for this exact (repo, commit) pair. |
| 232 | + * |
| 233 | + * @param {{ repoPath: string, repoFullName: string, commitSha: string }} input |
| 234 | + * @param {{ exec: import("./worktree-allocator.js").WorktreeExecFn, store?: ReturnType<typeof openReplaySnapshotStore> }} deps |
| 235 | + */ |
| 236 | +export async function exportReplaySnapshot(input, deps) { |
| 237 | + if (!input || typeof input !== "object") throw new Error("invalid_replay_snapshot_input"); |
| 238 | + const repoFullName = normalizeRepoFullName(input.repoFullName); |
| 239 | + const commitSha = normalizeCommitSha(input.commitSha); |
| 240 | + if (typeof input.repoPath !== "string" || !input.repoPath.trim()) throw new Error("invalid_repo_path"); |
| 241 | + const repoPath = input.repoPath.trim(); |
| 242 | + |
| 243 | + if (!deps || typeof deps !== "object" || typeof deps.exec !== "function") throw new Error("invalid_exec"); |
| 244 | + const { exec } = deps; |
| 245 | + const store = deps.store ?? getDefaultReplaySnapshotStore(); |
| 246 | + |
| 247 | + const cached = store.getSnapshot(repoFullName, commitSha); |
| 248 | + if (cached) return cached; |
| 249 | + |
| 250 | + const worktreePath = planReplaySnapshotPath({ repoPath, commitSha }); |
| 251 | + await addDetachedWorktree(exec, repoPath, worktreePath, commitSha); |
| 252 | + |
| 253 | + const targetDate = await readTargetCommitDate(exec, repoPath, commitSha); |
| 254 | + const commits = await readCommitHistory(exec, repoPath, commitSha); |
| 255 | + const tags = await readReachableTags(exec, repoPath, commitSha); |
| 256 | + const readme = await readReadmeAtCommit(exec, repoPath, commitSha); |
| 257 | + |
| 258 | + validateSnapshotFreshness({ targetDate, commits, tags }); |
| 259 | + |
| 260 | + return store.saveSnapshot({ repoFullName, commitSha, worktreePath, targetDate, commits, tags, readme }); |
| 261 | +} |
| 262 | + |
| 263 | +/** Tear down a replay snapshot's working-tree export (the cached context-bundle row is left in place -- it is |
| 264 | + * cheap, commit-keyed, and re-usable even after the on-disk tree is removed; only re-adding the worktree would |
| 265 | + * require the tree again, which is out of this function's scope). */ |
| 266 | +export async function removeReplaySnapshotWorktree(exec, repoPath, worktreePath) { |
| 267 | + return removeWorktree({ exec, repoPath, worktreePath }); |
| 268 | +} |
0 commit comments