| 1 | import { spawn } from "node:child_process"; |
| 2 | import { CliError, type Io } from "./io.js"; |
| 3 | |
| 4 | export interface GitResult { |
| 5 | code: number; |
| 6 | stdout: string; |
| 7 | stderr: string; |
| 8 | } |
| 9 | |
| 10 | /** Runs git and captures its output. */ |
| 11 | export 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). */ |
| 33 | export 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 | |
| 43 | export 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 | */ |
| 54 | export 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. */ |
| 68 | export 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). */ |
| 73 | export 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 | |
| 99 | export 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 | } |