Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

526 linesCodeBlameRaw
1// 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//
9// It is reached only by the Artifacts-compatible shim (workers/gitstore, the repos service's GITSTORE binding),
10// 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//
15// A key is a repository's name (`acme--rocket`), or a namespace and a name
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.
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//
26// 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(/\/$/, "");
41const READ_ONLY = ["1", "true", "yes"].includes(String(process.env.GITSTORE_READ_ONLY ?? "").toLowerCase());
42
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
65const NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,199}$/;
66const 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
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
83function repoDir(key) {
84 if (!validKey(key)) {
85 throw new StoreError("INVALID_REPO_NAME", `invalid repository name: ${key}`);
86 }
87 return join(ROOT, `${key}.git`);
88}
89
90function refuseWrites(what) {
91 if (READ_ONLY) throw new StoreError("READ_ONLY", `the git store is read-only: ${what} is refused`, 403);
92}
93
94function 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.toString().trim();
145 let lastPushAt = null;
146 try {
147 lastPushAt = statSync(join(dir, "g1t-pushed")).mtime.toISOString();
148 } catch {}
149 return {
150 id: meta.id ?? key,
151 name: key,
152 description: meta.description ?? null,
153 defaultBranch: head || "main",
154 createdAt: meta.createdAt ?? new Date(0).toISOString(),
155 updatedAt: lastPushAt ?? meta.createdAt ?? new Date(0).toISOString(),
156 lastPushAt,
157 source: meta.source ?? null,
158 readOnly: Boolean(meta.readOnly),
159 remote: `${PUBLIC_URL}/git/${key}.git`,
160 };
161}
162
163async function create(key, { description, defaultBranch, readOnly, source } = {}) {
164 refuseWrites("creating a repository");
165 const dir = repoDir(key);
166 if (exists(key)) throw new StoreError("ALREADY_EXISTS", `${key} already exists`, 409);
167 mkdirSync(dir, { recursive: true });
168 await git(["init", "--bare", "--quiet", `--initial-branch=${defaultBranch || "main"}`, dir]);
169 await configure(dir);
170 writeMeta(key, {
171 id: randomUUID(),
172 description: description ?? null,
173 createdAt: new Date().toISOString(),
174 readOnly: Boolean(readOnly),
175 source: source ?? null,
176 });
177 return info(key);
178}
179
180async function configure(dir) {
181 // Pushes arrive through git http-backend; the token has already been
182 // checked, so receive-pack is allowed for every write-scoped request.
183 await git(["config", "http.receivepack", "true"], { cwd: dir });
184 await git(["config", "receive.denyNonFastForwards", "false"], { cwd: dir });
185 await git(["config", "uploadpack.allowAnySHA1InWant", "true"], { cwd: dir });
186}
187
188async function fork(key, target, { description, readOnly, defaultBranchOnly = true } = {}) {
189 refuseWrites("forking");
190 const source = requireRepo(key);
191 const dir = repoDir(target);
192 if (exists(target)) throw new StoreError("ALREADY_EXISTS", `${target} already exists`, 409);
193 const args = ["clone", "--bare", "--quiet", "--no-tags"];
194 if (defaultBranchOnly) args.push("--single-branch");
195 // A local clone hard-links the objects: cheap, and independent of the
196 // source from then on.
197 args.push(source, dir);
198 await git(args);
199 await git(["remote", "remove", "origin"], { cwd: dir, allowFail: true });
200 await configure(dir);
201 writeMeta(target, {
202 id: randomUUID(),
203 description: description ?? readMeta(key).description ?? null,
204 createdAt: new Date().toISOString(),
205 readOnly: Boolean(readOnly),
206 source: `artifacts:${key}`,
207 });
208 return info(target);
209}
210
211// ── Reading objects ─────────────────────────────────────────────────────
212
213async function objectType(dir, spec) {
214 const { code, stdout } = await git(["cat-file", "-t", "--", spec], { cwd: dir, allowFail: true });
215 return code === 0 ? stdout.toString().trim() : null;
216}
217
218function person(line) {
219 // `Name <email> 1700000000 +0000`
220 const match = /^(.*) <([^>]*)> (\d+) [+-]\d{4}$/.exec(line);
221 return match ? { name: match[1], email: match[2], at: Number(match[3]) } : { name: line, email: "", at: 0 };
222}
223
224function parseCommit(hash, raw) {
225 const text = raw.toString("utf8");
226 const split = text.indexOf("\n\n");
227 const headers = (split === -1 ? text : text.slice(0, split)).split("\n");
228 let message = split === -1 ? "" : text.slice(split + 2);
229 if (message.endsWith("\n")) message = message.slice(0, -1);
230 const commit = { hash, treeHash: "", message, parents: [], author: null, committer: null };
231 for (const header of headers) {
232 const space = header.indexOf(" ");
233 const name = header.slice(0, space);
234 const value = header.slice(space + 1);
235 if (name === "tree") commit.treeHash = value;
236 else if (name === "parent") commit.parents.push(value);
237 else if (name === "author") commit.author = person(value);
238 else if (name === "committer") commit.committer = person(value);
239 }
240 const author = commit.author ?? { name: "", email: "", at: 0 };
241 const committer = commit.committer ?? author;
242 return {
243 hash,
244 treeHash: commit.treeHash,
245 message: commit.message,
246 author: { name: author.name, email: author.email },
247 committer: { name: committer.name, email: committer.email },
248 parents: commit.parents,
249 authoredAt: author.at,
250 committedAt: committer.at,
251 };
252}
253
254async function readCommit(key, hash) {
255 const dir = requireRepo(key);
256 if (!HASH.test(hash)) return null;
257 if ((await objectType(dir, hash)) !== "commit") return null;
258 return parseCommit(hash, (await git(["cat-file", "commit", hash], { cwd: dir })).stdout);
259}
260
261async function log(key, { ref = "HEAD", limit = 50, offset = 0 } = {}) {
262 const dir = requireRepo(key);
263 if (typeof ref !== "string" || ref.startsWith("-")) return [];
264 const count = Math.max(1, Math.min(Number(limit) || 50, 1000));
265 const skip = Math.max(0, Number(offset) || 0);
266 const listed = await git(["rev-list", "--first-parent", `--max-count=${count}`, `--skip=${skip}`, ref, "--"], { cwd: dir, allowFail: true });
267 if (listed.code !== 0) return [];
268 const hashes = listed.stdout.toString().split("\n").filter(Boolean);
269 const commits = [];
270 for (const hash of hashes) {
271 commits.push(parseCommit(hash, (await git(["cat-file", "commit", hash], { cwd: dir })).stdout));
272 }
273 return commits;
274}
275
276const TYPES = { "040000": "tree", 100644: "blob", 100755: "exec", 120000: "symlink", 160000: "gitlink" };
277
278async function readTree(key, hash) {
279 const dir = requireRepo(key);
280 if (!HASH.test(hash)) return null;
281 if ((await objectType(dir, hash)) !== "tree") return null;
282 const { stdout } = await git(["ls-tree", "-z", hash], { cwd: dir });
283 return stdout
284 .toString("utf8")
285 .split("\0")
286 .filter(Boolean)
287 .map((line) => {
288 const tab = line.indexOf("\t");
289 const [mode, , object] = line.slice(0, tab).split(" ");
290 return {
291 name: line.slice(tab + 1),
292 mode: mode === "040000" ? "40000" : mode,
293 hash: object,
294 type: TYPES[mode] ?? "blob",
295 };
296 });
297}
298
299async function readBlob(key, hash) {
300 const dir = requireRepo(key);
301 if (!HASH.test(hash)) return null;
302 if ((await objectType(dir, hash)) !== "blob") return null;
303 return (await git(["cat-file", "blob", hash], { cwd: dir })).stdout;
304}
305
306async function readFile(key, ref, path) {
307 const dir = requireRepo(key);
308 if (!ref || !path || ref.startsWith("-") || ref.includes(":")) return null;
309 const spec = `${ref}:${path.replace(/^\/+/, "")}`;
310 if ((await objectType(dir, spec)) !== "blob") return null;
311 return (await git(["cat-file", "blob", spec], { cwd: dir })).stdout;
312}
313
314// ── Tokens for git's smart HTTP ─────────────────────────────────────────
315
316function sign(payload) {
317 return createHmac("sha256", SECRET).update(payload).digest("base64url");
318}
319
320function mintToken(key, scope = "write", ttl = 86400) {
321 if (scope === "write") refuseWrites("a write token");
322 const seconds = Math.max(60, Math.min(Number(ttl) || 86400, 31536000));
323 const expires = Math.floor(Date.now() / 1000) + seconds;
324 const id = randomUUID();
325 const payload = Buffer.from(JSON.stringify({ k: key, s: scope, e: expires, i: id })).toString("base64url");
326 return {
327 id,
328 plaintext: `${payload}.${sign(payload)}`,
329 scope,
330 expiresAt: new Date(expires * 1000).toISOString(),
331 };
332}
333
334function checkToken(token, key) {
335 const [payload, signature] = String(token ?? "").split(".");
336 if (!payload || !signature) return null;
337 const expected = Buffer.from(sign(payload));
338 const given = Buffer.from(signature);
339 if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
340 const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
341 if (claims.k !== key || claims.e < Date.now() / 1000) return null;
342 return claims;
343}
344
345function bearer(request) {
346 const header = request.headers.authorization ?? "";
347 if (/^bearer /i.test(header)) return header.slice(7).trim();
348 if (/^basic /i.test(header)) {
349 // A git client given the token as a password: `x:<token>`.
350 const decoded = Buffer.from(header.slice(6).trim(), "base64").toString();
351 return decoded.slice(decoded.indexOf(":") + 1);
352 }
353 return null;
354}
355
356// ── Smart HTTP through git http-backend ─────────────────────────────────
357
358function smartHttp(request, response, key, rest, query) {
359 if (!exists(key)) return send(response, 404, "not found");
360 const claims = checkToken(bearer(request), key);
361 if (!claims) {
362 response.writeHead(401, { "www-authenticate": 'Basic realm="g1t-gitstore"' });
363 return response.end("unauthorized");
364 }
365 const service = rest === "info/refs" ? new URLSearchParams(query).get("service") : rest;
366 if (service === "git-receive-pack" && claims.s !== "write") return send(response, 403, "read-only token");
367 if (service === "git-receive-pack" && READ_ONLY) return send(response, 403, "the git store is read-only");
368 if (service !== "git-upload-pack" && service !== "git-receive-pack") return send(response, 404, "not found");
369
370 const env = {
371 PATH: process.env.PATH,
372 GIT_PROJECT_ROOT: ROOT,
373 GIT_HTTP_EXPORT_ALL: "1",
374 REQUEST_METHOD: request.method,
375 PATH_INFO: `/${key}.git/${rest}`,
376 QUERY_STRING: query,
377 CONTENT_TYPE: request.headers["content-type"] ?? "",
378 REMOTE_USER: "g1t",
379 REMOTE_ADDR: request.socket.remoteAddress ?? "",
380 };
381 if (request.headers["git-protocol"]) env.GIT_PROTOCOL = request.headers["git-protocol"];
382 if (request.headers["content-encoding"]) env.HTTP_CONTENT_ENCODING = request.headers["content-encoding"];
383 if (request.headers["content-length"]) env.CONTENT_LENGTH = request.headers["content-length"];
384
385 const child = spawn("git", ["http-backend"], { env, stdio: ["pipe", "pipe", "pipe"] });
386 request.pipe(child.stdin);
387 child.stderr.on("data", (chunk) => process.stderr.write(chunk));
388
389 // CGI: headers, a blank line, then the body.
390 let buffered = Buffer.alloc(0);
391 let headersDone = false;
392 child.stdout.on("data", (chunk) => {
393 if (headersDone) return response.write(chunk);
394 buffered = Buffer.concat([buffered, chunk]);
395 let end = buffered.indexOf("\r\n\r\n");
396 let gap = 4;
397 if (end === -1) {
398 end = buffered.indexOf("\n\n");
399 gap = 2;
400 }
401 if (end === -1) return;
402 headersDone = true;
403 let status = 200;
404 const headers = {};
405 for (const line of buffered.slice(0, end).toString().split(/\r?\n/)) {
406 const colon = line.indexOf(":");
407 if (colon === -1) continue;
408 const name = line.slice(0, colon).trim().toLowerCase();
409 const value = line.slice(colon + 1).trim();
410 if (name === "status") status = Number.parseInt(value, 10);
411 else headers[name] = value;
412 }
413 response.writeHead(status, headers);
414 response.write(buffered.slice(end + gap));
415 });
416 child.on("close", (code) => {
417 if (!headersDone) {
418 send(response, 500, "git http-backend failed");
419 return;
420 }
421 if (service === "git-receive-pack" && request.method === "POST" && code === 0) {
422 const marker = join(repoDir(key), "g1t-pushed");
423 try {
424 utimesSync(marker, new Date(), new Date());
425 } catch {
426 writeFileSync(marker, "");
427 }
428 }
429 response.end();
430 });
431}
432
433// ── HTTP ────────────────────────────────────────────────────────────────
434
435function send(response, status, body, headers = {}) {
436 const isBuffer = Buffer.isBuffer(body);
437 const payload = isBuffer ? body : typeof body === "string" ? body : JSON.stringify(body);
438 response.writeHead(status, {
439 "content-type": isBuffer ? "application/octet-stream" : typeof body === "string" ? "text/plain" : "application/json",
440 ...headers,
441 });
442 response.end(payload);
443}
444
445async function readJson(request) {
446 const chunks = [];
447 for await (const chunk of request) chunks.push(chunk);
448 const text = Buffer.concat(chunks).toString();
449 return text ? JSON.parse(text) : {};
450}
451
452function authorized(request) {
453 const given = Buffer.from(request.headers["x-gitstore-secret"] ?? "");
454 const expected = Buffer.from(SECRET);
455 return given.length === expected.length && timingSafeEqual(given, expected);
456}
457
458async function api(request, response, parts, params) {
459 if (!authorized(request)) return send(response, 401, { code: "UNAUTHORIZED", message: "bad secret" });
460 const method = request.method;
461 // POST /api/repos create
462 if (parts.length === 0 && method === "POST") {
463 const body = await readJson(request);
464 return send(response, 200, await create(body.name, body));
465 }
466 const [key, action, arg] = parts;
467 if (method === "GET" && !action) return send(response, 200, await info(key));
468 // DELETE /api/repos/<key> delete (a purged repository)
469 if (method === "DELETE" && !action) {
470 refuseWrites("deleting a repository");
471 if (!exists(key)) return send(response, 404, { code: "NOT_FOUND", message: "no such repository" });
472 await rm(repoDir(key), { recursive: true, force: true });
473 return send(response, 200, { deleted: true });
474 }
475 if (method === "POST" && action === "tokens") {
476 requireRepo(key);
477 const body = await readJson(request);
478 return send(response, 200, mintToken(key, body.scope, body.ttl));
479 }
480 if (method === "POST" && action === "fork") {
481 const body = await readJson(request);
482 return send(response, 200, await fork(key, body.name, body));
483 }
484 if (method === "GET" && action === "commits") {
485 return send(response, 200, await readCommit(key, arg));
486 }
487 if (method === "GET" && action === "log") {
488 return send(response, 200, await log(key, Object.fromEntries(params)));
489 }
490 if (method === "GET" && action === "trees") {
491 return send(response, 200, await readTree(key, arg));
492 }
493 if (method === "GET" && (action === "blobs" || action === "file")) {
494 const bytes = action === "blobs" ? await readBlob(key, arg) : await readFile(key, params.get("ref"), params.get("path"));
495 return bytes ? send(response, 200, bytes) : send(response, 404, { code: "NOT_FOUND", message: "no such object" });
496 }
497 return send(response, 404, { code: "NOT_FOUND", message: "no such route" });
498}
499
500const server = createServer(async (request, response) => {
501 const url = new URL(request.url, "http://gitstore");
502 try {
503 if (url.pathname === "/healthz") return send(response, 200, READ_ONLY ? "ok read-only" : "ok");
504 const git = /^\/git\/((?:[^/]+\/)?[^/]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/.exec(url.pathname);
505 if (git) return smartHttp(request, response, decodeURIComponent(git[1]), git[2], url.search.slice(1));
506 if (url.pathname === "/api/repos" || url.pathname.startsWith("/api/repos/")) {
507 const parts = url.pathname.slice("/api/repos".length).split("/").filter(Boolean).map(decodeURIComponent);
508 return await api(request, response, parts, url.searchParams);
509 }
510 send(response, 404, "not found");
511 } catch (error) {
512 const status = error instanceof StoreError ? error.status : 500;
513 const code = error instanceof StoreError ? error.code : "INTERNAL_ERROR";
514 if (status >= 500) console.error(error);
515 if (!response.headersSent) send(response, status, { code, message: error.message });
516 else response.end();
517 }
518});
519
520server.listen(PORT, () => {
521 console.log(`g1t gitstore: ${ROOT} on :${PORT} (remote ${PUBLIC_URL})${READ_ONLY ? ", read-only" : ""}`);
522});
523
524for (const signal of ["SIGINT", "SIGTERM"]) {
525 process.on(signal, () => server.close(() => process.exit(0)));
526}