mirror your GitHub repos to tangled.org automatically
1import { execa, type Options } from 'execa'
2
3/**
4 * Thin wrapper over `execa` for invoking the system `git` binary with
5 * predictable defaults.
6 *
7 * - Forces non-interactive mode so a misconfigured ssh setup never hangs
8 * waiting for a passphrase or `yes/no` prompt.
9 * - Captures stderr so callers can produce useful error messages.
10 * - Adds a default 60s timeout; callers can override via `options.timeout`.
11 */
12export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> {
13 const result = await execa('git', args, {
14 timeout: 60_000,
15 ...options,
16 env: {
17 // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0`
18 // makes git fail rather than hang if it would otherwise ask for input
19 // (e.g. credentials).
20 GIT_TERMINAL_PROMPT: '0',
21 // Don't pick up the running user's ssh config / known_hosts. The caller
22 // supplies a complete GIT_SSH_COMMAND for ssh transports.
23 GIT_CONFIG_NOSYSTEM: '1',
24 ...options.env,
25 },
26 // Buffer (default) is fine for small operations; for very large fetches
27 // we'd want to stream stderr instead.
28 reject: true,
29 all: true,
30 })
31 return { stdout: String(result.stdout), stderr: String(result.stderr) }
32}
33
34/**
35 * Recognised remote rejection patterns from the knot when a repo no longer
36 * exists or our key has been revoked. Surfaces as a typed error so the
37 * worker can mark the mapping as terminally failed rather than retry forever.
38 */
39export class RemoteRejectedPushError extends Error {
40 constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') {
41 super(message)
42 this.name = 'RemoteRejectedPushError'
43 }
44}
45
46export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null {
47 const lc = stderr.toLowerCase()
48 if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) {
49 return new RemoteRejectedPushError(stderr.trim(), 'repo-gone')
50 }
51 if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) {
52 return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected')
53 }
54 return null
55}