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.
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 1 | // The fallback path end to end: bundles cut as the |
| Merge branch 'worktree-agent-a2013627e5ea4ab13' | 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"); | |
| Ops tests commit unsigned in their scratch repositories, whatever the developer's signing setup | 26 | git(dir, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", text); |
| Merge branch 'worktree-agent-a2013627e5ea4ab13' | 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"); | |
| The three open dependency updates land together (#29, #33, #35): katex resolves to 0.19.0 everywhere, mermaid's nested copy included; query-string moves to 9.5.1, which takes decode-uri-component to 0.5.0; and the uuid that xcode locks moves from 7.0.3 to 11.1.1, which still loads under xcode's require. The ops tests' annotated tags carry their own git identity, so they run where git has none configured. | 125 | git(origin, "-c", "user.name=t", "-c", "user.email=t@example.com", "tag", "-a", "v1", "-m", "v1"); |
| Merge branch 'worktree-agent-a2013627e5ea4ab13' | 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.