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 | // The fallback path end to end (docs/ARTIFACTS.md, R12): bundles cut as the |
| 2 | // runner cuts them, restored into a git store's root, served by the git | |
| 3 | // store itself (deploy/self-host/gitstore/server.mjs, read-only), pushed to | |
| 4 | // while it serves, and reconciled back into the "live" repository. | |
| 5 | ||
| 6 | import assert from "node:assert/strict"; | |
| 7 | import { execFileSync, spawn, spawnSync } from "node:child_process"; | |
| 8 | import { createHash } from "node:crypto"; | |
| 9 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { test } from "node:test"; | |
| 13 | import { fileURLToPath } from "node:url"; | |
| 14 | ||
| 15 | import { parseRefs } from "./backup-restore-drill.mjs"; | |
| 16 | import { CONFLICT_PREFIX, changedSince, locate, metaFor, reconcilePlan, repoDir } from "./restore-to-gitstore.mjs"; | |
| 17 | ||
| 18 | const TOOL = fileURLToPath(new URL("./restore-to-gitstore.mjs", import.meta.url)); | |
| 19 | const SERVER = fileURLToPath(new URL("../../deploy/self-host/gitstore/server.mjs", import.meta.url)); | |
| 20 | const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); | |
| 21 | const tool = (...args) => spawnSync(process.execPath, [TOOL, ...args], { encoding: "utf8" }); | |
| 22 | ||
| 23 | function commit(dir, file, text) { | |
| 24 | writeFileSync(join(dir, file), text); | |
| 25 | git(dir, "add", "--all"); | |
| 26 | git(dir, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "--quiet", "-m", text); | |
| 27 | } | |
| 28 | ||
| 29 | function refsOf(dir) { | |
| 30 | const refs = parseRefs(git(dir, "for-each-ref", "--format=%(objectname) %(refname)")); | |
| 31 | refs.HEAD = git(dir, "rev-parse", "HEAD"); | |
| 32 | return refs; | |
| 33 | } | |
| 34 | ||
| 35 | /** A bucket holding one full backup of `origin` as `repo_1`, as the runner and repos service leave it. */ | |
| 36 | function backUp(root, origin, id, storeKey) { | |
| 37 | const mirror = join(root, `${id}-mirror.git`); | |
| 38 | git(root, "clone", "--mirror", "--quiet", origin, mirror); | |
| 39 | const dir = join(root, "bucket", "backups", id); | |
| 40 | mkdirSync(dir, { recursive: true }); | |
| 41 | const bundle = join(dir, "1-full.bundle"); | |
| 42 | git(mirror, "bundle", "create", "--quiet", bundle, "--all"); | |
| 43 | const entry = { | |
| 44 | id: "20261006T025300Z", | |
| 45 | kind: "full", | |
| 46 | key: `backups/${id}/1-full.bundle`, | |
| 47 | created_at: "2026-10-06T02:53:00.000Z", | |
| 48 | refs_version: 1, | |
| 49 | refs: refsOf(mirror), | |
| 50 | prerequisites: [], | |
| 51 | size: statSync(bundle).size, | |
| 52 | sha256: createHash("sha256").update(readFileSync(bundle)).digest("hex"), | |
| 53 | }; | |
| 54 | const manifest = { version: 1, repo_id: id, store_key: storeKey, path: { namespace: "acme", name: "rocket" }, updated_at: "", chain: [entry], previous: [] }; | |
| 55 | writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); | |
| 56 | return join(root, "bucket"); | |
| 57 | } | |
| 58 | ||
| 59 | /** The git store, serving `root`, until `stop()`. */ | |
| 60 | async function serve(root, { readOnly }) { | |
| 61 | const port = 41000 + Math.floor(Math.random() * 2000); | |
| 62 | const secret = "0123456789abcdef0123456789abcdef"; | |
| 63 | const child = spawn(process.execPath, [SERVER], { | |
| 64 | env: { ...process.env, GITSTORE_ROOT: root, GITSTORE_PORT: String(port), GITSTORE_SECRET: secret, GITSTORE_URL: `http://127.0.0.1:${port}`, GITSTORE_READ_ONLY: readOnly ? "1" : "" }, | |
| 65 | stdio: "ignore", | |
| 66 | }); | |
| 67 | const url = `http://127.0.0.1:${port}`; | |
| 68 | for (let tries = 0; tries < 100; tries++) { | |
| 69 | try { | |
| 70 | if ((await fetch(`${url}/healthz`)).ok) break; | |
| 71 | } catch {} | |
| 72 | await new Promise((resolve) => setTimeout(resolve, 100)); | |
| 73 | } | |
| 74 | const api = async (method, path, body) => { | |
| 75 | const response = await fetch(`${url}/api/repos${path}`, { | |
| 76 | method, | |
| 77 | headers: { "x-gitstore-secret": secret, ...(body ? { "content-type": "application/json" } : {}) }, | |
| 78 | body: body ? JSON.stringify(body) : undefined, | |
| 79 | }); | |
| 80 | return { status: response.status, json: await response.json().catch(() => ({})) }; | |
| 81 | }; | |
| 82 | return { url, secret, api, stop: () => child.kill() }; | |
| 83 | } | |
| 84 | ||
| 85 | test("keys, plans and what a restore records", () => { | |
| 86 | assert.deepEqual(locate("acme--rocket"), { namespace: "g1t", name: "acme--rocket" }); | |
| 87 | assert.deepEqual(locate("g1t-us-1/pulls--pul_1"), { namespace: "g1t-us-1", name: "pulls--pul_1" }); | |
| 88 | assert.throws(() => locate("../x"), /not a store key/); | |
| 89 | assert.equal(repoDir("/srv", "g1t", "acme--rocket").replaceAll("\\", "/"), "/srv/g1t/acme--rocket.git"); | |
| 90 | const a = "a".repeat(40); | |
| 91 | const b = "b".repeat(40); | |
| 92 | const c = "c".repeat(40); | |
| 93 | const changes = changedSince({ "refs/heads/main": a, "refs/heads/old": a }, { "refs/heads/main": b, "refs/heads/new": c }); | |
| 94 | assert.deepEqual(changes, [ | |
| 95 | { ref: "refs/heads/main", base: a, now: b }, | |
| 96 | { ref: "refs/heads/new", base: null, now: c }, | |
| 97 | { ref: "refs/heads/old", base: a, now: null }, | |
| 98 | ]); | |
| 99 | // Live still as backed up: moved. Live has it: nothing. Live moved too: kept beside. | |
| 100 | assert.deepEqual( | |
| 101 | reconcilePlan(changes, { "refs/heads/main": a, "refs/heads/old": c }).map((step) => [step.ref, step.action, step.kept ?? null]), | |
| 102 | [ | |
| 103 | ["refs/heads/main", "move", null], | |
| 104 | ["refs/heads/new", "move", null], | |
| 105 | ["refs/heads/old", "conflict", null], | |
| 106 | ], | |
| 107 | ); | |
| 108 | assert.equal(reconcilePlan(changes, { "refs/heads/main": c })[0].kept, `${CONFLICT_PREFIX}heads/main`); | |
| 109 | assert.equal(reconcilePlan(changes, { "refs/heads/main": b })[0].action, "same"); | |
| 110 | const manifest = { chain: [{ id: "e1", created_at: "t", refs: { HEAD: a, "refs/heads/main": a } }] }; | |
| 111 | const meta = metaFor({ id: "repo_1" }, manifest, "now", { id: "kept", createdAt: "then" }); | |
| 112 | assert.equal(meta.id, "kept"); | |
| 113 | assert.deepEqual(meta.restored.refs, { "refs/heads/main": a }); | |
| 114 | assert.equal(meta.restored.entry, "e1"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("restored repositories are served read-only, and pushes taken later are reconciled", async () => { | |
| 118 | const root = mkdtempSync(join(tmpdir(), "g1t-fallback-test-")); | |
| 119 | let server = null; | |
| 120 | try { | |
| 121 | const origin = join(root, "origin"); | |
| 122 | mkdirSync(origin); | |
| 123 | git(origin, "init", "--quiet", "--initial-branch=main"); | |
| 124 | commit(origin, "a.txt", "one"); | |
| 125 | git(origin, "tag", "-a", "v1", "-m", "v1"); | |
| 126 | const bucket = backUp(root, origin, "repo_1", "g1t-us-1/acme--rocket"); | |
| 127 | const index = join(root, "index.json"); | |
| 128 | writeFileSync(index, JSON.stringify([{ id: "repo_1", store: "g1t-us-1/acme--rocket", path: "acme/rocket" }, { id: "repo_2", store: "acme--gone", path: "acme/gone" }])); | |
| 129 | const store = join(root, "store"); | |
| 130 | ||
| 131 | const first = tool("restore", "--into", store, "--bundles", bucket, "--index", index); | |
| 132 | assert.equal(first.status, 0, first.stdout + first.stderr); | |
| 133 | assert.match(first.stdout, /1 restored, 0 already current, 1 without a backup/); | |
| 134 | const dir = repoDir(store, "g1t-us-1", "acme--rocket"); | |
| 135 | assert.ok(existsSync(join(dir, "g1t.json"))); | |
| 136 | assert.equal(git(dir, "rev-parse", "refs/heads/main"), git(origin, "rev-parse", "main")); | |
| 137 | // Again: nothing to do. | |
| 138 | const again = tool("restore", "--into", store, "--bundles", bucket, "--index", index); | |
| 139 | assert.match(again.stdout, /0 restored, 1 already current/); | |
| 140 | // Only a namespace asked for. | |
| 141 | assert.match(tool("restore", "--into", store, "--bundles", bucket, "--index", index, "--namespace", "g1t-eu").stdout, /^0 repositories/m); | |
| 142 | ||
| 143 | // Served as the repos service reads it: a namespaced key, a token, git. | |
| 144 | server = await serve(store, { readOnly: true }); | |
| 145 | const key = encodeURIComponent("g1t-us-1/acme--rocket"); | |
| 146 | const info = await server.api("GET", `/${key}`); | |
| 147 | assert.equal(info.status, 200); | |
| 148 | assert.equal(info.json.remote, `${server.url}/git/g1t-us-1/acme--rocket.git`); | |
| 149 | const read = await server.api("POST", `/${key}/tokens`, { scope: "read", ttl: 600 }); | |
| 150 | assert.equal(read.status, 200); | |
| 151 | const header = `http.extraHeader=Authorization: Bearer ${read.json.plaintext}`; | |
| 152 | const clone = join(root, "clone"); | |
| 153 | git(root, "-c", header, "clone", "--quiet", info.json.remote, clone); | |
| 154 | assert.equal(git(clone, "rev-parse", "HEAD"), git(origin, "rev-parse", "main")); | |
| 155 | // Read-only: no write token, no new repository. | |
| 156 | assert.equal((await server.api("POST", `/${key}/tokens`, { scope: "write" })).json.code, "READ_ONLY"); | |
| 157 | assert.equal((await server.api("POST", "", { name: "g1t-us-1/new" })).json.code, "READ_ONLY"); | |
| 158 | assert.equal((await server.api("GET", `/${encodeURIComponent("../etc")}`)).json.code, "INVALID_REPO_NAME"); | |
| 159 | server.stop(); | |
| 160 | server = null; | |
| 161 | ||
| 162 | // While it served with writes allowed, a push came in. | |
| 163 | assert.match(tool("changed", "--into", store).stdout, /^0 repositories/m); | |
| 164 | commit(clone, "b.txt", "pushed to the fallback"); | |
| 165 | git(clone, "push", "--quiet", dir, "HEAD:refs/heads/main", "HEAD:refs/heads/during"); | |
| 166 | const changed = tool("changed", "--into", store); | |
| 167 | assert.match(changed.stdout, /g1t-us-1\/acme--rocket \(repo_1\)/); | |
| 168 | assert.match(changed.stdout, /refs\/heads\/during/); | |
| 169 | ||
| 170 | // The live repository still as backed up: the push goes back as it is. | |
| 171 | const live = join(root, "live"); | |
| 172 | mkdirSync(join(live, "g1t-us-1"), { recursive: true }); | |
| 173 | git(root, "clone", "--bare", "--quiet", origin, repoDir(live, "g1t-us-1", "acme--rocket")); | |
| 174 | const reconciled = tool("reconcile", "--into", store, "--live-root", live, "--no-bump"); | |
| 175 | assert.equal(reconciled.status, 0, reconciled.stdout + reconciled.stderr); | |
| 176 | const liveDir = repoDir(live, "g1t-us-1", "acme--rocket"); | |
| 177 | assert.equal(git(liveDir, "rev-parse", "refs/heads/main"), git(clone, "rev-parse", "HEAD")); | |
| 178 | assert.equal(git(liveDir, "rev-parse", "refs/heads/during"), git(clone, "rev-parse", "HEAD")); | |
| 179 | ||
| 180 | // Moved on both sides: the live one stays, the fallback's goes beside it. | |
| 181 | rmSync(liveDir, { recursive: true, force: true }); | |
| 182 | commit(origin, "c.txt", "pushed to Artifacts before the outage, after the backup"); | |
| 183 | git(root, "clone", "--bare", "--quiet", origin, liveDir); | |
| 184 | const conflicted = tool("reconcile", "--into", store, "--live-root", live, "--no-bump"); | |
| 185 | assert.equal(conflicted.status, 3, conflicted.stdout + conflicted.stderr); | |
| 186 | assert.match(conflicted.stdout, /moved on both sides/); | |
| 187 | assert.equal(git(liveDir, "rev-parse", "refs/heads/main"), git(origin, "rev-parse", "main")); | |
| 188 | assert.equal(git(liveDir, "rev-parse", "refs/fallback/heads/main"), git(clone, "rev-parse", "HEAD")); | |
| 189 | } finally { | |
| 190 | server?.stop(); | |
| 191 | rmSync(root, { recursive: true, force: true }); | |
| 192 | } | |
| 193 | }); | |
| 194 | ||
| 195 | test("a git store over HTTP is restored into through its API", async () => { | |
| 196 | const root = mkdtempSync(join(tmpdir(), "g1t-fallback-http-")); | |
| 197 | let server = null; | |
| 198 | try { | |
| 199 | const origin = join(root, "origin"); | |
| 200 | mkdirSync(origin); | |
| 201 | git(origin, "init", "--quiet", "--initial-branch=main"); | |
| 202 | commit(origin, "a.txt", "one"); | |
| 203 | git(origin, "branch", "dev"); | |
| 204 | const bucket = backUp(root, origin, "repo_1", "acme--rocket"); | |
| 205 | const index = join(root, "index.json"); | |
| 206 | writeFileSync(index, JSON.stringify([{ id: "repo_1", store: "acme--rocket", default_branch: "main" }])); | |
| 207 | server = await serve(join(root, "store"), { readOnly: false }); | |
| 208 | const run = spawn(process.execPath, [TOOL, "restore", "--gitstore", server.url, "--bundles", bucket, "--index", index], { | |
| 209 | env: { ...process.env, GITSTORE_SECRET: server.secret }, | |
| 210 | }); | |
| 211 | let out = ""; | |
| 212 | run.stdout.on("data", (chunk) => (out += chunk)); | |
| 213 | run.stderr.on("data", (chunk) => (out += chunk)); | |
| 214 | const status = await new Promise((resolve) => run.on("close", resolve)); | |
| 215 | assert.equal(status, 0, out); | |
| 216 | // The default namespace's repositories are kept under it. | |
| 217 | const dir = repoDir(join(root, "store"), "g1t", "acme--rocket"); | |
| 218 | assert.equal(git(dir, "rev-parse", "refs/heads/dev"), git(origin, "rev-parse", "dev")); | |
| 219 | } finally { | |
| 220 | server?.stop(); | |
| 221 | rmSync(root, { recursive: true, force: true }); | |
| 222 | } | |
| 223 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.