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