Skip to content
532 linesCodeBlameRaw

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 runner base keeps its old comment, so the pushed base image still matches its folder1// g1t's git store for self-hosting: plain bare repositories on disk.
2//
3// Hosted g1t keeps repositories in Cloudflare Artifacts. This server does
4// the same job with nothing but git: one bare repository per store key
5// under GITSTORE_ROOT, git's own smart HTTP (git http-backend) for clones,
6// fetches and pushes, and a small JSON API for the reads the repos service
7// makes (commits, trees, blobs, files) and for creating and forking.
8//
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.9// It is reached only by the Artifacts-compatible shim (workers/gitstore, the repos service's GITSTORE binding),
The runner base keeps its old comment, so the pushed base image still matches its folder10// which the repos service is bound to in place of the Artifacts binding, and
11// by the repos service itself for git's smart HTTP. Nothing else should be
12// able to reach it: the API takes a shared secret, and git requests a
13// short-lived token the shim minted with the same secret.
14//
Merge branch 'worktree-agent-a2013627e5ea4ab13'15// A key is a repository's name (`acme--rocket`), or a namespace and a name
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.16// (`g1t/acme--rocket`): hosted g1t's fallback store
17// (scripts/ops/restore-to-gitstore.mjs) keeps each Artifacts namespace's
18// repositories in a directory of their own, so a remote reads
19// `<GITSTORE_URL>/git/<namespace>/<name>.git`, the shape Artifacts gives
20// remotes.
Merge branch 'worktree-agent-a2013627e5ea4ab13'21//
22// GITSTORE_READ_ONLY=1 refuses everything that writes: pushes, creating,
23// forking, deleting, and minting write tokens. As a fallback the store
24// serves reads until told otherwise; the repos service refuses writes too.
25//
The runner base keeps its old comment, so the pushed base image still matches its folder26// No dependencies beyond Node and git.
27
28import { spawn } from "node:child_process";
29import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
30import { existsSync, mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
31import { createServer } from "node:http";
32import { rm } from "node:fs/promises";
33import { dirname, join } from "node:path";
34
35const ROOT = process.env.GITSTORE_ROOT ?? "/data/git";
36const PORT = Number(process.env.GITSTORE_PORT ?? 8080);
37const SECRET = loadSecret();
38// How the repos service reaches this server; it becomes each repository's
39// `remote`, exactly as Artifacts hands one out.
40const PUBLIC_URL = (process.env.GITSTORE_URL ?? `http://localhost:${PORT}`).replace(/\/$/, "");
Merge branch 'worktree-agent-a2013627e5ea4ab13'41const READ_ONLY = ["1", "true", "yes"].includes(String(process.env.GITSTORE_READ_ONLY ?? "").toLowerCase());
The runner base keeps its old comment, so the pushed base image still matches its folder42
43/**
44 * The secret shared with the Artifacts shim: GITSTORE_SECRET, or else the
45 * one in GITSTORE_SECRET_FILE, made on first start. The compose file shares
46 * that file with the g1t container, so nobody has to choose one.
47 */
48function loadSecret() {
49 if (process.env.GITSTORE_SECRET) return process.env.GITSTORE_SECRET;
50 const file = process.env.GITSTORE_SECRET_FILE;
51 if (!file) return "";
52 if (!existsSync(file)) {
53 mkdirSync(dirname(file), { recursive: true });
54 writeFileSync(file, randomBytes(32).toString("hex"), { mode: 0o600 });
55 }
56 return readFileSync(file, "utf8").trim();
57}
58
59if (SECRET.length < 16) {
60 console.error("Set GITSTORE_SECRET (16 characters or more) or GITSTORE_SECRET_FILE.");
61 process.exit(1);
62}
63mkdirSync(ROOT, { recursive: true });
64
Merge branch 'worktree-agent-a2013627e5ea4ab13'65const NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,199}$/;
The runner base keeps its old comment, so the pushed base image still matches its folder66const HASH = /^[0-9a-f]{40}$/;
67
68class StoreError extends Error {
69 constructor(code, message, status = 400) {
70 super(message);
71 this.code = code;
72 this.status = status;
73 }
74}
75
Merge branch 'worktree-agent-a2013627e5ea4ab13'76/** Whether `key` is a name, or a namespace and a name. */
77function validKey(key) {
78 if (typeof key !== "string" || key.includes("..")) return false;
79 const parts = key.split("/");
80 return parts.length <= 2 && parts.every((part) => NAME.test(part));
81}
82
The runner base keeps its old comment, so the pushed base image still matches its folder83function repoDir(key) {
Merge branch 'worktree-agent-a2013627e5ea4ab13'84 if (!validKey(key)) {
The runner base keeps its old comment, so the pushed base image still matches its folder85 throw new StoreError("INVALID_REPO_NAME", `invalid repository name: ${key}`);
86 }
87 return join(ROOT, `${key}.git`);
88}
89
Merge branch 'worktree-agent-a2013627e5ea4ab13'90function refuseWrites(what) {
91 if (READ_ONLY) throw new StoreError("READ_ONLY", `the git store is read-only: ${what} is refused`, 403);
92}
93
The runner base keeps its old comment, so the pushed base image still matches its folder94function exists(key) {
95 return existsSync(join(repoDir(key), "HEAD"));
96}
97
98function requireRepo(key) {
99 if (!exists(key)) throw new StoreError("NOT_FOUND", `no repository ${key}`, 404);
100 return repoDir(key);
101}
102
103/** Runs git and resolves with its stdout as a Buffer. */
104function git(args, { cwd, input, allowFail = false } = {}) {
105 return new Promise((resolve, reject) => {
106 const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
107 const out = [];
108 const err = [];
109 child.stdout.on("data", (chunk) => out.push(chunk));
110 child.stderr.on("data", (chunk) => err.push(chunk));
111 child.on("error", reject);
112 child.on("close", (code) => {
113 if (code !== 0 && !allowFail) {
114 reject(new StoreError("INTERNAL_ERROR", `git ${args[0]} failed: ${Buffer.concat(err)}`, 500));
115 } else {
116 resolve({ code, stdout: Buffer.concat(out) });
117 }
118 });
119 child.stdin.end(input ?? undefined);
120 });
121}
122
123// ── Metadata kept beside each repository ────────────────────────────────
124
125function metaPath(key) {
126 return join(repoDir(key), "g1t.json");
127}
128
129function readMeta(key) {
130 try {
131 return JSON.parse(readFileSync(metaPath(key), "utf8"));
132 } catch {
133 return {};
134 }
135}
136
137function writeMeta(key, meta) {
138 writeFileSync(metaPath(key), JSON.stringify(meta, null, 2));
139}
140
141async function info(key) {
142 const dir = requireRepo(key);
143 const meta = readMeta(key);
144 const head = (await git(["symbolic-ref", "--short", "HEAD"], { cwd: dir, allowFail: true })).stdout
145 .toString()
146 .trim();
147 let lastPushAt = null;
148 try {
149 lastPushAt = statSync(join(dir, "g1t-pushed")).mtime.toISOString();
150 } catch {}
151 return {
152 id: meta.id ?? key,
153 name: key,
154 description: meta.description ?? null,
155 defaultBranch: head || "main",
156 createdAt: meta.createdAt ?? new Date(0).toISOString(),
157 updatedAt: lastPushAt ?? meta.createdAt ?? new Date(0).toISOString(),
158 lastPushAt,
159 source: meta.source ?? null,
160 readOnly: Boolean(meta.readOnly),
161 remote: `${PUBLIC_URL}/git/${key}.git`,
162 };
163}
164
165async function create(key, { description, defaultBranch, readOnly, source } = {}) {
Merge branch 'worktree-agent-a2013627e5ea4ab13'166 refuseWrites("creating a repository");
The runner base keeps its old comment, so the pushed base image still matches its folder167 const dir = repoDir(key);
168 if (exists(key)) throw new StoreError("ALREADY_EXISTS", `${key} already exists`, 409);
169 mkdirSync(dir, { recursive: true });
170 await git(["init", "--bare", "--quiet", `--initial-branch=${defaultBranch || "main"}`, dir]);
171 await configure(dir);
172 writeMeta(key, {
173 id: randomUUID(),
174 description: description ?? null,
175 createdAt: new Date().toISOString(),
176 readOnly: Boolean(readOnly),
177 source: source ?? null,
178 });
179 return info(key);
180}
181
182async function configure(dir) {
183 // Pushes arrive through git http-backend; the token has already been
184 // checked, so receive-pack is allowed for every write-scoped request.
185 await git(["config", "http.receivepack", "true"], { cwd: dir });
186 await git(["config", "receive.denyNonFastForwards", "false"], { cwd: dir });
187 await git(["config", "uploadpack.allowAnySHA1InWant", "true"], { cwd: dir });
188}
189
190async function fork(key, target, { description, readOnly, defaultBranchOnly = true } = {}) {
Merge branch 'worktree-agent-a2013627e5ea4ab13'191 refuseWrites("forking");
The runner base keeps its old comment, so the pushed base image still matches its folder192 const source = requireRepo(key);
193 const dir = repoDir(target);
194 if (exists(target)) throw new StoreError("ALREADY_EXISTS", `${target} already exists`, 409);
195 const args = ["clone", "--bare", "--quiet", "--no-tags"];
196 if (defaultBranchOnly) args.push("--single-branch");
197 // A local clone hard-links the objects: cheap, and independent of the
198 // source from then on.
199 args.push(source, dir);
200 await git(args);
201 await git(["remote", "remove", "origin"], { cwd: dir, allowFail: true });
202 await configure(dir);
203 writeMeta(target, {
204 id: randomUUID(),
205 description: description ?? readMeta(key).description ?? null,
206 createdAt: new Date().toISOString(),
207 readOnly: Boolean(readOnly),
208 source: `artifacts:${key}`,
209 });
210 return info(target);
211}
212
213// ── Reading objects ─────────────────────────────────────────────────────
214
215async function objectType(dir, spec) {
216 const { code, stdout } = await git(["cat-file", "-t", "--", spec], { cwd: dir, allowFail: true });
217 return code === 0 ? stdout.toString().trim() : null;
218}
219
220function person(line) {
221 // `Name <email> 1700000000 +0000`
222 const match = /^(.*) <([^>]*)> (\d+) [+-]\d{4}$/.exec(line);
223 return match ? { name: match[1], email: match[2], at: Number(match[3]) } : { name: line, email: "", at: 0 };
224}
225
226function parseCommit(hash, raw) {
227 const text = raw.toString("utf8");
228 const split = text.indexOf("\n\n");
229 const headers = (split === -1 ? text : text.slice(0, split)).split("\n");
230 let message = split === -1 ? "" : text.slice(split + 2);
231 if (message.endsWith("\n")) message = message.slice(0, -1);
232 const commit = { hash, treeHash: "", message, parents: [], author: null, committer: null };
233 for (const header of headers) {
234 const space = header.indexOf(" ");
235 const name = header.slice(0, space);
236 const value = header.slice(space + 1);
237 if (name === "tree") commit.treeHash = value;
238 else if (name === "parent") commit.parents.push(value);
239 else if (name === "author") commit.author = person(value);
240 else if (name === "committer") commit.committer = person(value);
241 }
242 const author = commit.author ?? { name: "", email: "", at: 0 };
243 const committer = commit.committer ?? author;
244 return {
245 hash,
246 treeHash: commit.treeHash,
247 message: commit.message,
248 author: { name: author.name, email: author.email },
249 committer: { name: committer.name, email: committer.email },
250 parents: commit.parents,
251 authoredAt: author.at,
252 committedAt: committer.at,
253 };
254}
255
256async function readCommit(key, hash) {
257 const dir = requireRepo(key);
258 if (!HASH.test(hash)) return null;
259 if ((await objectType(dir, hash)) !== "commit") return null;
260 return parseCommit(hash, (await git(["cat-file", "commit", hash], { cwd: dir })).stdout);
261}
262
263async function log(key, { ref = "HEAD", limit = 50, offset = 0 } = {}) {
264 const dir = requireRepo(key);
265 if (typeof ref !== "string" || ref.startsWith("-")) return [];
266 const count = Math.max(1, Math.min(Number(limit) || 50, 1000));
267 const skip = Math.max(0, Number(offset) || 0);
268 const listed = await git(
269 ["rev-list", "--first-parent", `--max-count=${count}`, `--skip=${skip}`, ref, "--"],
270 { cwd: dir, allowFail: true },
271 );
272 if (listed.code !== 0) return [];
273 const hashes = listed.stdout.toString().split("\n").filter(Boolean);
274 const commits = [];
275 for (const hash of hashes) {
276 commits.push(parseCommit(hash, (await git(["cat-file", "commit", hash], { cwd: dir })).stdout));
277 }
278 return commits;
279}
280
281const TYPES = { "040000": "tree", "100644": "blob", "100755": "exec", "120000": "symlink", "160000": "gitlink" };
282
283async function readTree(key, hash) {
284 const dir = requireRepo(key);
285 if (!HASH.test(hash)) return null;
286 if ((await objectType(dir, hash)) !== "tree") return null;
287 const { stdout } = await git(["ls-tree", "-z", hash], { cwd: dir });
288 return stdout
289 .toString("utf8")
290 .split("\0")
291 .filter(Boolean)
292 .map((line) => {
293 const tab = line.indexOf("\t");
294 const [mode, , object] = line.slice(0, tab).split(" ");
295 return {
296 name: line.slice(tab + 1),
297 mode: mode === "040000" ? "40000" : mode,
298 hash: object,
299 type: TYPES[mode] ?? "blob",
300 };
301 });
302}
303
304async function readBlob(key, hash) {
305 const dir = requireRepo(key);
306 if (!HASH.test(hash)) return null;
307 if ((await objectType(dir, hash)) !== "blob") return null;
308 return (await git(["cat-file", "blob", hash], { cwd: dir })).stdout;
309}
310
311async function readFile(key, ref, path) {
312 const dir = requireRepo(key);
313 if (!ref || !path || ref.startsWith("-") || ref.includes(":")) return null;
314 const spec = `${ref}:${path.replace(/^\/+/, "")}`;
315 if ((await objectType(dir, spec)) !== "blob") return null;
316 return (await git(["cat-file", "blob", spec], { cwd: dir })).stdout;
317}
318
319// ── Tokens for git's smart HTTP ─────────────────────────────────────────
320
321function sign(payload) {
322 return createHmac("sha256", SECRET).update(payload).digest("base64url");
323}
324
325function mintToken(key, scope = "write", ttl = 86400) {
Merge branch 'worktree-agent-a2013627e5ea4ab13'326 if (scope === "write") refuseWrites("a write token");
The runner base keeps its old comment, so the pushed base image still matches its folder327 const seconds = Math.max(60, Math.min(Number(ttl) || 86400, 31536000));
328 const expires = Math.floor(Date.now() / 1000) + seconds;
329 const id = randomUUID();
330 const payload = Buffer.from(JSON.stringify({ k: key, s: scope, e: expires, i: id })).toString("base64url");
331 return {
332 id,
333 plaintext: `${payload}.${sign(payload)}`,
334 scope,
335 expiresAt: new Date(expires * 1000).toISOString(),
336 };
337}
338
339function checkToken(token, key) {
340 const [payload, signature] = String(token ?? "").split(".");
341 if (!payload || !signature) return null;
342 const expected = Buffer.from(sign(payload));
343 const given = Buffer.from(signature);
344 if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
345 const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
346 if (claims.k !== key || claims.e < Date.now() / 1000) return null;
347 return claims;
348}
349
350function bearer(request) {
351 const header = request.headers.authorization ?? "";
352 if (/^bearer /i.test(header)) return header.slice(7).trim();
353 if (/^basic /i.test(header)) {
354 // A git client given the token as a password: `x:<token>`.
355 const decoded = Buffer.from(header.slice(6).trim(), "base64").toString();
356 return decoded.slice(decoded.indexOf(":") + 1);
357 }
358 return null;
359}
360
361// ── Smart HTTP through git http-backend ─────────────────────────────────
362
363function smartHttp(request, response, key, rest, query) {
364 if (!exists(key)) return send(response, 404, "not found");
365 const claims = checkToken(bearer(request), key);
366 if (!claims) {
367 response.writeHead(401, { "www-authenticate": 'Basic realm="g1t-gitstore"' });
368 return response.end("unauthorized");
369 }
370 const service = rest === "info/refs" ? new URLSearchParams(query).get("service") : rest;
371 if (service === "git-receive-pack" && claims.s !== "write") return send(response, 403, "read-only token");
Merge branch 'worktree-agent-a2013627e5ea4ab13'372 if (service === "git-receive-pack" && READ_ONLY) return send(response, 403, "the git store is read-only");
The runner base keeps its old comment, so the pushed base image still matches its folder373 if (service !== "git-upload-pack" && service !== "git-receive-pack") return send(response, 404, "not found");
374
375 const env = {
376 PATH: process.env.PATH,
377 GIT_PROJECT_ROOT: ROOT,
378 GIT_HTTP_EXPORT_ALL: "1",
379 REQUEST_METHOD: request.method,
380 PATH_INFO: `/${key}.git/${rest}`,
381 QUERY_STRING: query,
382 CONTENT_TYPE: request.headers["content-type"] ?? "",
383 REMOTE_USER: "g1t",
384 REMOTE_ADDR: request.socket.remoteAddress ?? "",
385 };
386 if (request.headers["git-protocol"]) env.GIT_PROTOCOL = request.headers["git-protocol"];
387 if (request.headers["content-encoding"]) env.HTTP_CONTENT_ENCODING = request.headers["content-encoding"];
388 if (request.headers["content-length"]) env.CONTENT_LENGTH = request.headers["content-length"];
389
390 const child = spawn("git", ["http-backend"], { env, stdio: ["pipe", "pipe", "pipe"] });
391 request.pipe(child.stdin);
392 child.stderr.on("data", (chunk) => process.stderr.write(chunk));
393
394 // CGI: headers, a blank line, then the body.
395 let buffered = Buffer.alloc(0);
396 let headersDone = false;
397 child.stdout.on("data", (chunk) => {
398 if (headersDone) return response.write(chunk);
399 buffered = Buffer.concat([buffered, chunk]);
400 let end = buffered.indexOf("\r\n\r\n");
401 let gap = 4;
402 if (end === -1) {
403 end = buffered.indexOf("\n\n");
404 gap = 2;
405 }
406 if (end === -1) return;
407 headersDone = true;
408 let status = 200;
409 const headers = {};
410 for (const line of buffered.slice(0, end).toString().split(/\r?\n/)) {
411 const colon = line.indexOf(":");
412 if (colon === -1) continue;
413 const name = line.slice(0, colon).trim().toLowerCase();
414 const value = line.slice(colon + 1).trim();
415 if (name === "status") status = Number.parseInt(value, 10);
416 else headers[name] = value;
417 }
418 response.writeHead(status, headers);
419 response.write(buffered.slice(end + gap));
420 });
421 child.on("close", (code) => {
422 if (!headersDone) {
423 send(response, 500, "git http-backend failed");
424 return;
425 }
426 if (service === "git-receive-pack" && request.method === "POST" && code === 0) {
427 const marker = join(repoDir(key), "g1t-pushed");
428 try {
429 utimesSync(marker, new Date(), new Date());
430 } catch {
431 writeFileSync(marker, "");
432 }
433 }
434 response.end();
435 });
436}
437
438// ── HTTP ────────────────────────────────────────────────────────────────
439
440function send(response, status, body, headers = {}) {
441 const isBuffer = Buffer.isBuffer(body);
442 const payload = isBuffer ? body : typeof body === "string" ? body : JSON.stringify(body);
443 response.writeHead(status, {
444 "content-type": isBuffer ? "application/octet-stream" : typeof body === "string" ? "text/plain" : "application/json",
445 ...headers,
446 });
447 response.end(payload);
448}
449
450async function readJson(request) {
451 const chunks = [];
452 for await (const chunk of request) chunks.push(chunk);
453 const text = Buffer.concat(chunks).toString();
454 return text ? JSON.parse(text) : {};
455}
456
457function authorized(request) {
458 const given = Buffer.from(request.headers["x-gitstore-secret"] ?? "");
459 const expected = Buffer.from(SECRET);
460 return given.length === expected.length && timingSafeEqual(given, expected);
461}
462
463async function api(request, response, parts, params) {
464 if (!authorized(request)) return send(response, 401, { code: "UNAUTHORIZED", message: "bad secret" });
465 const method = request.method;
466 // POST /api/repos create
467 if (parts.length === 0 && method === "POST") {
468 const body = await readJson(request);
469 return send(response, 200, await create(body.name, body));
470 }
471 const [key, action, arg] = parts;
472 if (method === "GET" && !action) return send(response, 200, await info(key));
473 // DELETE /api/repos/<key> delete (a purged repository)
474 if (method === "DELETE" && !action) {
Merge branch 'worktree-agent-a2013627e5ea4ab13'475 refuseWrites("deleting a repository");
The runner base keeps its old comment, so the pushed base image still matches its folder476 if (!exists(key)) return send(response, 404, { code: "NOT_FOUND", message: "no such repository" });
477 await rm(repoDir(key), { recursive: true, force: true });
478 return send(response, 200, { deleted: true });
479 }
480 if (method === "POST" && action === "tokens") {
481 requireRepo(key);
482 const body = await readJson(request);
483 return send(response, 200, mintToken(key, body.scope, body.ttl));
484 }
485 if (method === "POST" && action === "fork") {
486 const body = await readJson(request);
487 return send(response, 200, await fork(key, body.name, body));
488 }
489 if (method === "GET" && action === "commits") {
490 return send(response, 200, await readCommit(key, arg));
491 }
492 if (method === "GET" && action === "log") {
493 return send(response, 200, await log(key, Object.fromEntries(params)));
494 }
495 if (method === "GET" && action === "trees") {
496 return send(response, 200, await readTree(key, arg));
497 }
498 if (method === "GET" && (action === "blobs" || action === "file")) {
499 const bytes =
500 action === "blobs" ? await readBlob(key, arg) : await readFile(key, params.get("ref"), params.get("path"));
501 return bytes ? send(response, 200, bytes) : send(response, 404, { code: "NOT_FOUND", message: "no such object" });
502 }
503 return send(response, 404, { code: "NOT_FOUND", message: "no such route" });
504}
505
506const server = createServer(async (request, response) => {
507 const url = new URL(request.url, "http://gitstore");
508 try {
Merge branch 'worktree-agent-a2013627e5ea4ab13'509 if (url.pathname === "/healthz") return send(response, 200, READ_ONLY ? "ok read-only" : "ok");
510 const git = /^\/git\/((?:[^/]+\/)?[^/]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(url.pathname);
The runner base keeps its old comment, so the pushed base image still matches its folder511 if (git) return smartHttp(request, response, decodeURIComponent(git[1]), git[2], url.search.slice(1));
512 if (url.pathname === "/api/repos" || url.pathname.startsWith("/api/repos/")) {
513 const parts = url.pathname.slice("/api/repos".length).split("/").filter(Boolean).map(decodeURIComponent);
514 return await api(request, response, parts, url.searchParams);
515 }
516 send(response, 404, "not found");
517 } catch (error) {
518 const status = error instanceof StoreError ? error.status : 500;
519 const code = error instanceof StoreError ? error.code : "INTERNAL_ERROR";
520 if (status >= 500) console.error(error);
521 if (!response.headersSent) send(response, status, { code, message: error.message });
522 else response.end();
523 }
524});
525
526server.listen(PORT, () => {
Merge branch 'worktree-agent-a2013627e5ea4ab13'527 console.log(`g1t gitstore: ${ROOT} on :${PORT} (remote ${PUBLIC_URL})${READ_ONLY ? ", read-only" : ""}`);
The runner base keeps its old comment, so the pushed base image still matches its folder528});
529
530for (const signal of ["SIGINT", "SIGTERM"]) {
531 process.on(signal, () => server.close(() => process.exit(0)));
532}

This file's history is long; its oldest lines are credited to the oldest commit read.