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.

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