mirror your GitHub repos to tangled.org automatically
1import {
2 type ReceivePackFactory,
3 ReceivePackSession,
4 type RefUpdate,
5 ssh2ReceivePackFactory,
6} from './git-wire/receive-pack'
7import { ZERO_SHA } from './git-wire/refs'
8import { fetchPack } from './git-wire/upload-pack'
9import { loadSshKeyForInstall } from './ssh-cmd'
10import { sshEndpointForKnot } from './sync-push-host'
11
12const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024
13/** Cap haves so a repo with thousands of refs can't bloat the negotiation. */
14const MAX_HAVES = 256
15
16function maxPackBytes(): number {
17 const raw = process.env.NUXT_MAX_PACK_BYTES
18 if (!raw) return DEFAULT_MAX_PACK_BYTES
19 const n = Number.parseInt(raw, 10)
20 return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n
21}
22
23async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<ReceivePackFactory> {
24 const privateKey = await loadSshKeyForInstall(installationId)
25 const { host, port } = sshEndpointForKnot(knot)
26 // ssh:// path form: leading slash, the knot resolves the repo by DID.
27 return ssh2ReceivePackFactory({ host, port, repoPath: `/${repoDid}`, privateKey })
28}
29
30export interface SplicePushParams {
31 installationId: number
32 repoFullName: string
33 knot: string
34 repoDid: string
35 /** Fully-qualified ref, e.g. `refs/heads/main`. */
36 ref: string
37 /** The SHA to land on the knot. */
38 want: string
39 /** GitHub installation token authorising the fetch. */
40 token: string
41}
42
43export interface SplicePushResult {
44 status: 'synced' | 'already-synced'
45 sha: string
46}
47
48/**
49 * Stream a single ref update from GitHub to the knot without materialising a
50 * repository:
51 *
52 * 1. open receive-pack, read the knot's tips;
53 * 2. if the knot's tip for `ref` already equals `want`, no-op;
54 * 3. fetch a thin pack from GitHub with the knot's tips as haves;
55 * 4. send the compare-and-swap command and pipe the pack straight through;
56 * 5. read report-status.
57 *
58 * Steps 1 and 3 share one ssh session: it sits idle for the duration of the
59 * GitHub round-trip (receive-pack waits indefinitely for commands), which
60 * keeps the knot's advertised tip as the authoritative compare-and-swap base.
61 */
62export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> {
63 const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
64 return runSplice(factory, params)
65}
66
67/** The fetch + push exchange over an open session. Split out for the wire test. */
68export async function runSplice(
69 factory: ReceivePackFactory,
70 params: { repoFullName: string, ref: string, want: string, token: string },
71): Promise<SplicePushResult> {
72 const session = await ReceivePackSession.open(factory)
73 let pushStarted = false
74 try {
75 const old = session.tips.get(params.ref) ?? ZERO_SHA
76 if (old === params.want) {
77 await session.close()
78 return { status: 'already-synced', sha: params.want }
79 }
80
81 const haves = [...new Set(session.tips.values())]
82 .filter(sha => sha !== ZERO_SHA)
83 .slice(0, MAX_HAVES)
84
85 const { pack } = await fetchPack({
86 repoFullName: params.repoFullName,
87 token: params.token,
88 want: params.want,
89 haves,
90 maxBytes: maxPackBytes(),
91 })
92
93 const update: RefUpdate = { ref: params.ref, old, next: params.want }
94 pushStarted = true
95 await session.push([update], pack)
96 return { status: 'synced', sha: params.want }
97 }
98 finally {
99 // `push` tears the session down itself; only close here if we threw before
100 // reaching it (e.g. the byte cap fired inside fetchPack's stream).
101 if (!pushStarted) await session.close()
102 }
103}
104
105export interface SpliceDeleteResult {
106 status: 'synced' | 'already-absent'
107}
108
109/**
110 * Delete a ref on the knot. No GitHub leg and no pack: read the knot's
111 * advertisement, and if the ref is absent we're already done (idempotent).
112 * Otherwise send a delete command with the advertised value as the
113 * compare-and-swap base.
114 */
115export async function spliceDelete(params: {
116 installationId: number
117 knot: string
118 repoDid: string
119 ref: string
120}): Promise<SpliceDeleteResult> {
121 const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
122 return runSpliceDelete(factory, params.ref)
123}
124
125/** The delete exchange over an open session. Split out for the wire test. */
126export async function runSpliceDelete(factory: ReceivePackFactory, ref: string): Promise<SpliceDeleteResult> {
127 const session = await ReceivePackSession.open(factory)
128 const old = session.tips.get(ref)
129 if (!old || old === ZERO_SHA) {
130 await session.close()
131 return { status: 'already-absent' }
132 }
133 // push() owns teardown for the success and rejection paths.
134 await session.push([{ ref, old, next: ZERO_SHA }], null)
135 return { status: 'synced' }
136}