mirror your GitHub repos to tangled.org automatically
1/**
2 * Typed failures from the git wire splice. `reason` drives the worker's
3 * retry-vs-give-up decision in `sync-push.ts` / `sync-ref.ts`.
4 *
5 * - repo-gone the knot no longer has the repo (or our key was revoked
6 * such that it reports "not found"); terminal, mark error.
7 * - auth-rejected ssh public-key auth refused; terminal, mark error.
8 * - stale-old-sha our compare-and-swap lost: the knot's ref moved between
9 * reading its advertisement and sending the command, or a
10 * concurrent worker won. Transient; retry re-reads the tip.
11 * - too-big the pack exceeded the configured byte cap; terminal, it
12 * will never fit.
13 * - other anything unclassified; transient, let the queue retry.
14 */
15export type WireFailureReason
16 = | 'repo-gone'
17 | 'auth-rejected'
18 | 'stale-old-sha'
19 | 'too-big'
20 | 'other'
21
22export class WireError extends Error {
23 constructor(message: string) {
24 super(message)
25 this.name = 'WireError'
26 }
27}
28
29export class RemoteRejectedError extends WireError {
30 constructor(message: string, public readonly reason: WireFailureReason) {
31 super(message)
32 this.name = 'RemoteRejectedError'
33 }
34}
35
36/**
37 * Classify ssh / sshd / knot stderr (the child process's stderr band, since
38 * we deliberately do not request side-band multiplexing). Returns null when
39 * nothing matches so the caller can fall back to a generic transient error.
40 */
41export function classifySshStderr(stderr: string): RemoteRejectedError | null {
42 const lc = stderr.toLowerCase()
43 if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) {
44 return new RemoteRejectedError(stderr.trim(), 'repo-gone')
45 }
46 if (lc.includes('permission denied') || (lc.includes('publickey') && lc.includes('denied'))) {
47 return new RemoteRejectedError(stderr.trim(), 'auth-rejected')
48 }
49 return null
50}
51
52/**
53 * Classify a receive-pack `ng <ref> <reason>` rejection. Any rejection that
54 * means "the ref's current value is not what you said" maps to stale-old-sha
55 * so the worker retries against a fresh advertisement. git phrases this two
56 * ways: `non-fast-forward` / `stale info` when updating a moved ref, and
57 * `failed to update ref` (stderr: "reference already exists") when our command
58 * claimed a create but the ref already exists.
59 */
60export function classifyNgReason(reason: string): RemoteRejectedError {
61 const lc = reason.toLowerCase()
62 if (
63 lc.includes('non-fast-forward')
64 || lc.includes('fetch first')
65 || lc.includes('stale info')
66 || lc.includes('not a fast forward')
67 || lc.includes('failed to update ref')
68 || lc.includes('reference already exists')
69 ) {
70 return new RemoteRejectedError(reason.trim(), 'stale-old-sha')
71 }
72 if (lc.includes('not found') || lc.includes('does not exist')) {
73 return new RemoteRejectedError(reason.trim(), 'repo-gone')
74 }
75 return new RemoteRejectedError(reason.trim(), 'other')
76}