Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

103 lines · 4.4 KBRaw History
1import { spawn } from "node:child_process";
2import { CliError, type Io } from "./io.js";
3
4export interface GitResult {
5 code: number;
6 stdout: string;
7 stderr: string;
8}
9
10/** Runs git and captures its output. */
11export function gitCapture(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<GitResult> {
12 return new Promise((resolve) => {
13 let child;
14 try {
15 child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
16 } catch (e) {
17 return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) });
18 }
19 let stdout = "";
20 let stderr = "";
21 child.stdout?.on("data", (d: Buffer) => (stdout += d.toString()));
22 child.stderr?.on("data", (d: Buffer) => (stderr += d.toString()));
23 child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message }));
24 child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
25 if (child.stdin) {
26 child.stdin.on("error", () => {}); // git may exit before reading its input
27 child.stdin.end(o.input);
28 }
29 });
30}
31
32/** Runs git with output going to the user's terminal (clone/push progress). */
33export function gitInteractive(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<number> {
34 return new Promise((resolve, reject) => {
35 const child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["inherit", "pipe", "pipe"] });
36 child.stdout.on("data", (d: Buffer) => io.stdout.write(d.toString()));
37 child.stderr.on("data", (d: Buffer) => io.stderr.write(d.toString()));
38 child.on("error", (e) => reject(new CliError(`could not run git: ${e.message}`)));
39 child.on("close", (code) => resolve(code ?? 1));
40 });
41}
42
43export async function git(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> {
44 const r = await gitCapture(io, args, o);
45 if (r.code !== 0) throw new CliError(`git ${args[0]} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
46 return r.stdout.trim();
47}
48
49/**
50 * Environment that adds one-off git config for a single git invocation
51 * (GIT_CONFIG_COUNT/KEY/VALUE, git 2.31+). Unlike `-c`, values do not show up
52 * in the process list, and like `-c` they are never written to .git/config.
53 */
54export function withGitConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv {
55 const out = { ...env };
56 let n = Number(out.GIT_CONFIG_COUNT ?? 0);
57 if (!Number.isInteger(n) || n < 0) n = 0;
58 for (const [k, v] of entries) {
59 out[`GIT_CONFIG_KEY_${n}`] = k;
60 out[`GIT_CONFIG_VALUE_${n}`] = v;
61 n++;
62 }
63 out.GIT_CONFIG_COUNT = String(n);
64 return out;
65}
66
67/** `http.<url>.extraHeader` entry that authenticates git HTTP requests to `baseUrl` only. */
68export function basicAuthHeader(baseUrl: string, user: string, password: string): [string, string] {
69 return [`http.${baseUrl.replace(/\/$/, "")}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`];
70}
71
72/** Parses owner/name out of a remote URL that points at `host` (https, ssh or scp-like). */
73export function parseRemote(remote: string, host: string): { owner: string; name: string } | null {
74 const r = remote.trim();
75 let h: string;
76 let p: string;
77 const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(r);
78 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(r)) {
79 try {
80 const u = new URL(r);
81 h = u.host;
82 p = decodeURIComponent(u.pathname);
83 if (u.protocol === "ssh:" && u.port) h = u.hostname; // ssh port differs from the web port
84 } catch {
85 return null;
86 }
87 } else if (scp) {
88 h = scp[1]!;
89 p = scp[2]!;
90 } else return null;
91 const want = host.toLowerCase();
92 if (h.toLowerCase() !== want && h.toLowerCase() !== want.replace(/:\d+$/, "")) return null;
93 const parts = p.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
94 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
95 const name = parts[1].replace(/\.git$/, "");
96 return name ? { owner: parts[0], name } : null;
97}
98
99export async function currentBranch(io: Io): Promise<string> {
100 const r = await gitCapture(io, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
101 if (r.code !== 0 || !r.stdout.trim()) throw new CliError("could not determine the current branch (detached HEAD or not a git repository); pass --head");
102 return r.stdout.trim();
103}