Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a2013627e5ea4ab13' | 1 | #!/usr/bin/env node |
| 2 | // Rebuilds repositories from the nightly backups into a git store, the cold | |
| 3 | // fallback for an Artifacts outage (docs/ARTIFACTS.md, R12), and afterwards | |
| 4 | // sends back what was pushed to it while it served. | |
| 5 | // | |
| 6 | // The store is deploy/self-host/gitstore: bare repositories under a root, | |
| 7 | // one directory per Artifacts namespace, `<root>/<namespace>/<name>.git`. | |
| 8 | // The repos service reads them through GIT_FALLBACK_URL once a namespace | |
| 9 | // is switched to it (services/repos/src/fallback.rs). | |
| 10 | // | |
| 11 | // node scripts/ops/restore-to-gitstore.mjs index > index.json | |
| 12 | // node scripts/ops/restore-to-gitstore.mjs restore --into /srv/gitstore --bundles /srv/backups | |
| 13 | // node scripts/ops/restore-to-gitstore.mjs restore --gitstore https://gitstore.example # GITSTORE_SECRET | |
| 14 | // node scripts/ops/restore-to-gitstore.mjs changed --into /srv/gitstore | |
| 15 | // node scripts/ops/restore-to-gitstore.mjs reconcile --into /srv/gitstore | |
| 16 | // | |
| 17 | // Commands: | |
| 18 | // index Every repository with a backup, its id and store key, from | |
| 19 | // the g1t-repos database (read-only), as JSON. Keep a recent | |
| 20 | // one on the fallback host: `restore --index` needs no database. | |
| 21 | // restore Each repository's chain, verified as the restore drill | |
| 22 | // verifies it (scripts/ops/backup-restore-drill.mjs), into the | |
| 23 | // store. One already restored from the same last backup is | |
| 24 | // left alone, so a second run only does what changed. | |
| 25 | // changed The restored repositories whose refs moved since they were | |
| 26 | // restored: pushes the fallback took (GIT_FALLBACK_WRITES=allow). | |
| 27 | // reconcile Sends those back to Artifacts. A ref that Artifacts still has | |
| 28 | // as it was backed up is moved to what the fallback has; one | |
| 29 | // that moved on both sides is kept beside it, as | |
| 30 | // refs/fallback/<the rest of its name>, for its owners to merge. | |
| 31 | // Then each one's refs_version is moved in the database, so | |
| 32 | // nothing kept from before is served. | |
| 33 | // | |
| 34 | // Options: | |
| 35 | // --into <root> the git store's root on this machine (GITSTORE_ROOT). | |
| 36 | // --gitstore <url> a git store reached over HTTP instead, with | |
| 37 | // GITSTORE_SECRET; it must not be read-only while | |
| 38 | // restoring. `changed` and `reconcile` need --into. | |
| 39 | // --bundles <dir> read the bucket from a local copy (`backups/<id>/...`, | |
| 40 | // as `rclone copy r2:g1t-backups <dir>` leaves it); | |
| 41 | // without it each object is read through Wrangler. | |
| 42 | // --index <file> the repositories from `index`'s output, not the database. | |
| 43 | // --namespace <name> only repositories in this Artifacts namespace. | |
| 44 | // --repo <id> only this repository (repeatable). | |
| 45 | // --default-namespace the namespace keys without one are in (default g1t). | |
| 46 | // --jobs <n> repositories at once (default 4). | |
| 47 | // --live-root <dir> reconcile into bare repositories here | |
| 48 | // (`<dir>/<namespace>/<name>.git`), for drills and tests, | |
| 49 | // instead of Artifacts. | |
| 50 | // --no-bump reconcile without moving refs_version. | |
| 51 | // | |
| 52 | // Artifacts is reached for `reconcile` with CLOUDFLARE_API_TOKEN (Artifacts | |
| 53 | // edit), or CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL; the database and the | |
| 54 | // bucket through Wrangler, as the restore drill does. | |
| 55 | ||
| 56 | import { randomUUID } from "node:crypto"; | |
| 57 | import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; | |
| 58 | import { mkdtemp } from "node:fs/promises"; | |
| 59 | import { tmpdir } from "node:os"; | |
| 60 | import { dirname, join } from "node:path"; | |
| 61 | ||
| 62 | import { cloudflareAuth, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs"; | |
| 63 | import { ROOT } from "../deploy/stack.mjs"; | |
| 64 | import { compareRefs, parseRefs, readManifest, restore } from "./backup-restore-drill.mjs"; | |
| 65 | ||
| 66 | const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js"); | |
| 67 | const DATABASE = "g1t-repos"; | |
| 68 | const BUCKET = process.env.BACKUP_BUCKET || "g1t-backups"; | |
| 69 | const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "1e6f2cffa3f445920836e8ebe446bb58"; | |
| 70 | const NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,199}$/; | |
| 71 | /** Where refs that moved on both sides are kept. */ | |
| 72 | export const CONFLICT_PREFIX = "refs/fallback/"; | |
| 73 | ||
| 74 | async function git(args, { cwd, input } = {}) { | |
| 75 | const { code, out } = await exec("git", args, { cwd, input }); | |
| 76 | if (code !== 0) throw new Error(`git ${args.find((arg) => !arg.startsWith("-")) ?? ""} failed: ${out.trim().slice(-600)}`); | |
| 77 | return out.trim(); | |
| 78 | } | |
| 79 | ||
| 80 | /** A store key's Artifacts namespace and name: `g1t-us-1/acme--rocket`, or the default's. */ | |
| 81 | export function locate(store, defaultNamespace = "g1t") { | |
| 82 | const at = store.indexOf("/"); | |
| 83 | const [namespace, name] = at >= 0 ? [store.slice(0, at), store.slice(at + 1)] : [defaultNamespace, store]; | |
| 84 | if (!NAME.test(namespace) || !NAME.test(name)) throw new Error(`not a store key: ${store}`); | |
| 85 | return { namespace, name }; | |
| 86 | } | |
| 87 | ||
| 88 | /** Where a repository lives under the git store's root. */ | |
| 89 | export function repoDir(root, namespace, name) { | |
| 90 | return join(root, namespace, `${name}.git`); | |
| 91 | } | |
| 92 | ||
| 93 | /** What the git store keeps beside a repository (gitstore/server.mjs `g1t.json`), with what it was restored from. */ | |
| 94 | export function metaFor(target, manifest, now = new Date().toISOString(), existing = {}) { | |
| 95 | const last = manifest.chain.at(-1); | |
| 96 | return { | |
| 97 | id: existing.id ?? randomUUID(), | |
| 98 | description: existing.description ?? null, | |
| 99 | createdAt: existing.createdAt ?? now, | |
| 100 | readOnly: false, | |
| 101 | source: null, | |
| 102 | restored: { | |
| 103 | repo_id: target.id, | |
| 104 | entry: last.id, | |
| 105 | backed_up_at: last.created_at, | |
| 106 | restored_at: now, | |
| 107 | refs: Object.fromEntries(Object.entries(last.refs).filter(([ref]) => ref !== "HEAD")), | |
| 108 | }, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | function readMeta(dir) { | |
| 113 | try { | |
| 114 | return JSON.parse(readFileSync(join(dir, "g1t.json"), "utf8")); | |
| 115 | } catch { | |
| 116 | return {}; | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | /** As the git store configures a repository it makes. */ | |
| 121 | async function configure(dir) { | |
| 122 | await git(["config", "http.receivepack", "true"], { cwd: dir }); | |
| 123 | await git(["config", "receive.denyNonFastForwards", "false"], { cwd: dir }); | |
| 124 | await git(["config", "uploadpack.allowAnySHA1InWant", "true"], { cwd: dir }); | |
| 125 | } | |
| 126 | ||
| 127 | /** Every ref of a bare repository but HEAD. */ | |
| 128 | async function refsIn(dir) { | |
| 129 | return parseRefs(await git(["for-each-ref", "--format=%(objectname) %(refname)"], { cwd: dir })); | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * What changed in a restored repository since it was restored: the refs | |
| 134 | * pushed to or deleted from it, each with the restored value (`base`) and | |
| 135 | * what it has now (`now`), `null` for absent. | |
| 136 | */ | |
| 137 | export function changedSince(restoredRefs, current) { | |
| 138 | return compareRefs(restoredRefs, current).map(({ ref, want, have }) => ({ ref, base: want, now: have })); | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * How each changed ref goes back to the live repository, which has `live` | |
| 143 | * now: `move` (the live ref is still what was backed up, so it takes the | |
| 144 | * fallback's value, or is deleted), `same` (it has it already), or | |
| 145 | * `conflict` (it moved too: the fallback's value goes beside it, under | |
| 146 | * CONFLICT_PREFIX). | |
| 147 | */ | |
| 148 | export function reconcilePlan(changes, live) { | |
| 149 | return changes.map(({ ref, base, now }) => { | |
| 150 | const current = live[ref] ?? null; | |
| 151 | if (current === now) return { ref, action: "same", from: current, to: now }; | |
| 152 | if (current === base) return { ref, action: "move", from: current, to: now }; | |
| 153 | return { ref, action: "conflict", from: current, to: now, kept: now ? CONFLICT_PREFIX + ref.replace(/^refs\//, "") : null }; | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | // --------------------------------------------------------------------- | |
| 158 | ||
| 159 | async function d1(sql) { | |
| 160 | const { code, out } = await exec(process.execPath, [WRANGLER, "d1", "execute", DATABASE, "--remote", "--json", "--command", sql], { | |
| 161 | cwd: join(ROOT, "services/repos"), | |
| 162 | env: wranglerEnv(), | |
| 163 | }); | |
| 164 | if (code !== 0) throw new Error(out.slice(-600)); | |
| 165 | return jsonFrom(out)[0]?.results ?? []; | |
| 166 | } | |
| 167 | ||
| 168 | const quoted = (text) => `'${String(text).replaceAll("'", "''")}'`; | |
| 169 | ||
| 170 | /** Every live repository with a backup: id, store key, path. */ | |
| 171 | async function indexFromDatabase() { | |
| 172 | const rows = await d1(`SELECT r.id, coalesce(r.store, r.namespace || '--' || r.name) AS store, r.namespace AS workspace, r.name, | |
| 173 | r.default_branch, b.last_entry | |
| 174 | FROM repos r JOIN repo_backups b ON b.repo_id = r.id | |
| 175 | WHERE r.deleted_at IS NULL AND r.fork_of IS NULL AND b.last_entry IS NOT NULL | |
| 176 | ORDER BY r.id`); | |
| 177 | return rows.map((row) => ({ id: row.id, store: row.store, path: `${row.workspace}/${row.name}`, default_branch: row.default_branch })); | |
| 178 | } | |
| 179 | ||
| 180 | /** Without a database: what each manifest in a local copy of the bucket says. */ | |
| 181 | function indexFromBundles(bundles) { | |
| 182 | const base = join(bundles, "backups"); | |
| 183 | if (!existsSync(base)) return []; | |
| 184 | return readdirSync(base) | |
| 185 | .filter((id) => existsSync(join(base, id, "manifest.json"))) | |
| 186 | .map((id) => { | |
| 187 | const manifest = JSON.parse(readFileSync(join(base, id, "manifest.json"), "utf8")); | |
| 188 | const path = manifest.path ? `${manifest.path.namespace}/${manifest.path.name}` : null; | |
| 189 | return { id, store: manifest.store_key, path }; | |
| 190 | }); | |
| 191 | } | |
| 192 | ||
| 193 | function bucketReader(localCopy) { | |
| 194 | if (localCopy) return async (key) => join(localCopy, key); | |
| 195 | return async (key, file) => { | |
| 196 | const { code, out } = await exec(process.execPath, [WRANGLER, "r2", "object", "get", `${BUCKET}/${key}`, "--remote", "--file", file], { | |
| 197 | env: wranglerEnv(), | |
| 198 | }); | |
| 199 | if (code !== 0) throw new Error(`${key} could not be read: ${out.slice(-400)}`); | |
| 200 | return file; | |
| 201 | }; | |
| 202 | } | |
| 203 | ||
| 204 | /** Runs `work` over `items`, `jobs` at a time. */ | |
| 205 | async function pool(items, jobs, work) { | |
| 206 | const results = []; | |
| 207 | let next = 0; | |
| 208 | const lanes = Array.from({ length: Math.max(1, Math.min(jobs, items.length)) }, async () => { | |
| 209 | while (next < items.length) { | |
| 210 | const at = next++; | |
| 211 | results[at] = await work(items[at]); | |
| 212 | } | |
| 213 | }); | |
| 214 | await Promise.all(lanes); | |
| 215 | return results; | |
| 216 | } | |
| 217 | ||
| 218 | /** The git store's API, over HTTP. */ | |
| 219 | function gitstoreApi(url, secret) { | |
| 220 | const base = url.replace(/\/$/, ""); | |
| 221 | return async (method, path, body) => { | |
| 222 | const response = await fetch(`${base}/api/repos${path}`, { | |
| 223 | method, | |
| 224 | headers: { "x-gitstore-secret": secret, ...(body ? { "content-type": "application/json" } : {}) }, | |
| 225 | body: body ? JSON.stringify(body) : undefined, | |
| 226 | }); | |
| 227 | const json = await response.json().catch(() => ({})); | |
| 228 | return { status: response.status, json }; | |
| 229 | }; | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * Restores one repository. Returns what happened: `restored`, `current` | |
| 234 | * (already restored from the same last backup), or `missing` (no backup). | |
| 235 | */ | |
| 236 | export async function restoreOne(target, { read, into, gitstore, defaultNamespace = "g1t", work }) { | |
| 237 | const { namespace, name } = locate(target.store, defaultNamespace); | |
| 238 | const scratch = await mkdtemp(join(work ?? tmpdir(), "g1t-restore-")); | |
| 239 | try { | |
| 240 | let manifestFile; | |
| 241 | try { | |
| 242 | manifestFile = await read(`backups/${target.id}/manifest.json`, join(scratch, "manifest.json")); | |
| 243 | } catch { | |
| 244 | return { id: target.id, namespace, name, result: "missing" }; | |
| 245 | } | |
| 246 | if (!existsSync(manifestFile)) return { id: target.id, namespace, name, result: "missing" }; | |
| 247 | const manifest = readManifest(readFileSync(manifestFile, "utf8")); | |
| 248 | const last = manifest.chain.at(-1); | |
| 249 | if (into) { | |
| 250 | const dir = repoDir(into, namespace, name); | |
| 251 | const existing = readMeta(dir); | |
| 252 | if (existing.restored?.entry === last.id && existing.restored?.repo_id === target.id) { | |
| 253 | return { id: target.id, namespace, name, result: "current" }; | |
| 254 | } | |
| 255 | // Built beside it, then put in place, so a reader never sees half. | |
| 256 | const building = `${dir}.restoring-${process.pid}`; | |
| 257 | rmSync(building, { recursive: true, force: true }); | |
| 258 | mkdirSync(dirname(dir), { recursive: true }); | |
| 259 | const refs = await restore(manifest, read, building, scratch); | |
| 260 | await configure(building); | |
| 261 | writeFileSync(join(building, "g1t.json"), JSON.stringify(metaFor(target, manifest, undefined, existing), null, 2)); | |
| 262 | rmSync(dir, { recursive: true, force: true }); | |
| 263 | renameSync(building, dir); | |
| 264 | return { id: target.id, namespace, name, result: "restored", refs: Object.keys(refs).length }; | |
| 265 | } | |
| 266 | // Over HTTP: rebuilt here, then pushed as a mirror. | |
| 267 | const local = join(scratch, "restored.git"); | |
| 268 | const refs = await restore(manifest, read, local, scratch); | |
| 269 | const key = `${namespace}/${name}`; | |
| 270 | const made = await gitstore.api("POST", "", { name: key, defaultBranch: target.default_branch ?? "main" }); | |
| 271 | if (made.status !== 200 && made.json.code !== "ALREADY_EXISTS") throw new Error(`${key}: ${made.json.message ?? made.status}`); | |
| 272 | const token = await gitstore.api("POST", `/${encodeURIComponent(key)}/tokens`, { scope: "write", ttl: 3600 }); | |
| 273 | if (token.status !== 200) throw new Error(`${key}: ${token.json.message ?? token.status}`); | |
| 274 | const remote = `${gitstore.url.replace(/\/$/, "")}/git/${key}.git`; | |
| 275 | await git(["-c", `http.extraHeader=Authorization: Bearer ${token.json.plaintext}`, "push", "--quiet", "--mirror", remote], { cwd: local }); | |
| 276 | return { id: target.id, namespace, name, result: "restored", refs: Object.keys(refs).length }; | |
| 277 | } finally { | |
| 278 | rmSync(scratch, { recursive: true, force: true }); | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | /** The restored repositories under `root`, with what each was restored from. */ | |
| 283 | function restoredUnder(root) { | |
| 284 | const out = []; | |
| 285 | if (!existsSync(root)) return out; | |
| 286 | for (const namespace of readdirSync(root)) { | |
| 287 | const dir = join(root, namespace); | |
| 288 | if (!NAME.test(namespace) || !existsSync(dir)) continue; | |
| 289 | for (const entry of readdirSync(dir)) { | |
| 290 | if (!entry.endsWith(".git")) continue; | |
| 291 | const meta = readMeta(join(dir, entry)); | |
| 292 | if (meta.restored) out.push({ namespace, name: entry.slice(0, -4), dir: join(dir, entry), restored: meta.restored }); | |
| 293 | } | |
| 294 | } | |
| 295 | return out; | |
| 296 | } | |
| 297 | ||
| 298 | /** Repositories that took pushes since they were restored. */ | |
| 299 | export async function changedRepos(root) { | |
| 300 | const out = []; | |
| 301 | for (const repo of restoredUnder(root)) { | |
| 302 | const changes = changedSince(repo.restored.refs, await refsIn(repo.dir)); | |
| 303 | if (changes.length) out.push({ ...repo, changes }); | |
| 304 | } | |
| 305 | return out; | |
| 306 | } | |
| 307 | ||
| 308 | /** Where to push a repository back to: Artifacts, or a bare repository under `--live-root`. */ | |
| 309 | function liveRemote(liveRoot) { | |
| 310 | if (liveRoot) return async (namespace, name) => ({ url: repoDir(liveRoot, namespace, name), args: [] }); | |
| 311 | const auth = cloudflareAuth(); | |
| 312 | if (!auth) throw new Error("set CLOUDFLARE_API_TOKEN (Artifacts edit), or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL, or --live-root"); | |
| 313 | const api = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/artifacts/namespaces`; | |
| 314 | return async (namespace, name) => { | |
| 315 | const at = `${api}/${namespace}/repos/${encodeURIComponent(name)}`; | |
| 316 | const info = await (await fetch(at, { headers: auth })).json().catch(() => ({})); | |
| 317 | const minted = await ( | |
| 318 | await fetch(`${at}/tokens`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ scope: "write", ttl: 3600 }) }) | |
| 319 | ) | |
| 320 | .json() | |
| 321 | .catch(() => ({})); | |
| 322 | const token = minted.result?.plaintext ?? minted.result?.token; | |
| 323 | if (!token) throw new Error(`${namespace}/${name}: Artifacts gave no write token (${JSON.stringify(minted.errors ?? minted).slice(0, 300)})`); | |
| 324 | const url = info.result?.remote ?? `https://${ACCOUNT_ID}.artifacts.cloudflare.net/git/${namespace}/${name}.git`; | |
| 325 | // The token as Artifacts gives it, `?expires=` and all, as g1t sends it. | |
| 326 | return { url, args: ["-c", `http.extraHeader=Authorization: Bearer ${token}`] }; | |
| 327 | }; | |
| 328 | } | |
| 329 | ||
| 330 | /** Sends one repository's changes back; what was done with each ref. */ | |
| 331 | export async function reconcileOne(repo, remoteFor) { | |
| 332 | const { url, args } = await remoteFor(repo.namespace, repo.name); | |
| 333 | const live = parseRefs(await git([...args, "ls-remote", url], { cwd: repo.dir })); | |
| 334 | const plan = reconcilePlan(repo.changes, live); | |
| 335 | const specs = []; | |
| 336 | for (const step of plan) { | |
| 337 | if (step.action === "move") { | |
| 338 | const lease = `--force-with-lease=${step.ref}:${step.from ?? ""}`; | |
| 339 | specs.push({ lease, spec: step.to ? `+${step.to}:${step.ref}` : `:${step.ref}` }); | |
| 340 | } else if (step.action === "conflict" && step.kept) { | |
| 341 | specs.push({ lease: null, spec: `+${step.to}:${step.kept}` }); | |
| 342 | } | |
| 343 | } | |
| 344 | if (specs.length) { | |
| 345 | // Not --atomic: whether Artifacts takes it is not documented (docs/ARTIFACTS.md, Q6). | |
| 346 | // Each move is leased on the value read above, so one that moved since is refused, not overwritten. | |
| 347 | const leases = specs.map((one) => one.lease).filter(Boolean); | |
| 348 | await git([...args, "push", "--quiet", ...leases, url, ...specs.map((one) => one.spec)], { cwd: repo.dir }); | |
| 349 | } | |
| 350 | return plan; | |
| 351 | } | |
| 352 | ||
| 353 | async function main() { | |
| 354 | const argv = process.argv.slice(2); | |
| 355 | const command = argv[0]; | |
| 356 | const option = (name) => { | |
| 357 | const at = argv.indexOf(name); | |
| 358 | return at >= 0 ? argv[at + 1] : undefined; | |
| 359 | }; | |
| 360 | const many = (name) => argv.flatMap((arg, at) => (arg === name && argv[at + 1] ? [argv[at + 1]] : [])); | |
| 361 | const defaultNamespace = option("--default-namespace") ?? "g1t"; | |
| 362 | const into = option("--into"); | |
| 363 | ||
| 364 | if (command === "index") { | |
| 365 | console.log(JSON.stringify(await indexFromDatabase(), null, 2)); | |
| 366 | return 0; | |
| 367 | } | |
| 368 | ||
| 369 | if (command === "restore") { | |
| 370 | const url = option("--gitstore"); | |
| 371 | if (!into && !url) throw new Error("say where to restore to: --into <root> or --gitstore <url>"); | |
| 372 | const gitstore = url ? { url, api: gitstoreApi(url, process.env.GITSTORE_SECRET ?? "") } : null; | |
| 373 | const bundles = option("--bundles"); | |
| 374 | let targets = option("--index") | |
| 375 | ? JSON.parse(readFileSync(option("--index"), "utf8")) | |
| 376 | : bundles && argv.includes("--offline") | |
| 377 | ? indexFromBundles(bundles) | |
| 378 | : await indexFromDatabase(); | |
| 379 | const only = many("--repo"); | |
| 380 | if (only.length) targets = targets.filter((target) => only.includes(target.id)); | |
| 381 | const namespace = option("--namespace"); | |
| 382 | if (namespace) targets = targets.filter((target) => locate(target.store, defaultNamespace).namespace === namespace); | |
| 383 | const read = bucketReader(bundles); | |
| 384 | const started = Date.now(); | |
| 385 | const counts = { restored: 0, current: 0, missing: 0, failed: 0 }; | |
| 386 | await pool(targets, Number(option("--jobs") ?? 4), async (target) => { | |
| 387 | try { | |
| 388 | const done = await restoreOne(target, { read, into, gitstore, defaultNamespace }); | |
| 389 | counts[done.result] += 1; | |
| 390 | if (done.result !== "current") console.log(`${done.result.padEnd(8)} ${done.namespace}/${done.name} (${target.path ?? target.id})`); | |
| 391 | } catch (error) { | |
| 392 | counts.failed += 1; | |
| 393 | console.error(`failed ${target.store} (${target.id}): ${error.message}`); | |
| 394 | } | |
| 395 | }); | |
| 396 | const seconds = ((Date.now() - started) / 1000).toFixed(0); | |
| 397 | console.log( | |
| 398 | `${targets.length} repositories in ${seconds}s: ${counts.restored} restored, ${counts.current} already current, ${counts.missing} without a backup, ${counts.failed} failed`, | |
| 399 | ); | |
| 400 | return counts.failed ? 1 : 0; | |
| 401 | } | |
| 402 | ||
| 403 | if (command === "changed" || command === "reconcile") { | |
| 404 | if (!into) throw new Error(`${command} reads the git store's root: --into <root>`); | |
| 405 | const changed = await changedRepos(into); | |
| 406 | if (command === "changed") { | |
| 407 | for (const repo of changed) { | |
| 408 | console.log(`${repo.namespace}/${repo.name} (${repo.restored.repo_id})`); | |
| 409 | for (const { ref, base, now } of repo.changes) console.log(` ${ref}: ${base ?? "(none)"} -> ${now ?? "(deleted)"}`); | |
| 410 | } | |
| 411 | console.log(`${changed.length} repositories took pushes since they were restored`); | |
| 412 | return 0; | |
| 413 | } | |
| 414 | const remoteFor = liveRemote(option("--live-root")); | |
| 415 | const bumped = []; | |
| 416 | let conflicts = 0; | |
| 417 | let failed = 0; | |
| 418 | for (const repo of changed) { | |
| 419 | try { | |
| 420 | const plan = await reconcileOne(repo, remoteFor); | |
| 421 | bumped.push(repo.restored.repo_id); | |
| 422 | for (const step of plan) { | |
| 423 | if (step.action === "conflict") conflicts += 1; | |
| 424 | const what = step.action === "conflict" ? `moved on both sides; the fallback's kept as ${step.kept ?? "(it deleted it)"}` : step.action; | |
| 425 | console.log(`${repo.namespace}/${repo.name} ${step.ref}: ${what}`); | |
| 426 | } | |
| 427 | } catch (error) { | |
| 428 | failed += 1; | |
| 429 | console.error(`${repo.namespace}/${repo.name}: ${error.message}`); | |
| 430 | } | |
| 431 | } | |
| 432 | if (bumped.length && !argv.includes("--no-bump")) { | |
| 433 | await d1(`UPDATE repos SET refs_version = coalesce(refs_version, 0) + 1 WHERE id IN (${bumped.map(quoted).join(", ")})`); | |
| 434 | } | |
| 435 | console.log(`${changed.length} repositories reconciled: ${conflicts} refs kept beside a newer one, ${failed} failed`); | |
| 436 | return failed ? 1 : conflicts ? 3 : 0; | |
| 437 | } | |
| 438 | ||
| 439 | console.error("usage: restore-to-gitstore.mjs index | restore | changed | reconcile (see the top of the file)"); | |
| 440 | return 2; | |
| 441 | } | |
| 442 | ||
| 443 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/ops/restore-to-gitstore.mjs")) { | |
| 444 | main().then( | |
| 445 | (code) => process.exit(code), | |
| 446 | (error) => { | |
| 447 | console.error(`restore: ${error.message}`); | |
| 448 | process.exit(2); | |
| 449 | }, | |
| 450 | ); | |
| 451 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.