mirror your GitHub repos to tangled.org automatically
1import { type ChildProcessWithoutNullStreams, execFileSync, spawn, spawnSync } from 'node:child_process'
2import { mkdtempSync, rmSync } from 'node:fs'
3import os from 'node:os'
4import path from 'node:path'
5import type { ReceivePackFactory } from '../../server/utils/git-wire/receive-pack'
6import { encodePktLine, flushPkt } from '../../server/utils/git-wire/pkt-line'
7
8/**
9 * Local git fixtures for wire-protocol tests. No network, no ssh: we drive the
10 * same `git-upload-pack` / `git-receive-pack` binaries GitHub and the knot run
11 * server-side, so the bytes on the pipe are real protocol output.
12 */
13export class GitFixture {
14 readonly dir: string
15
16 constructor() {
17 this.dir = mkdtempSync(path.join(os.tmpdir(), 'gitwire-test-'))
18 }
19
20 git(args: string[], cwd = this.dir): string {
21 return execFileSync('git', args, {
22 cwd,
23 encoding: 'utf8',
24 env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
25 }).trim()
26 }
27
28 initBare(name: string): string {
29 const repo = path.join(this.dir, name)
30 this.git(['init', '-q', '--bare', repo])
31 this.git(['symbolic-ref', 'HEAD', 'refs/heads/__synchub_placeholder'], repo)
32 return repo
33 }
34
35 /** Create a non-bare work repo with one commit on `main`, return its path. */
36 initWork(name: string): string {
37 const repo = path.join(this.dir, name)
38 this.git(['init', '-q', '-b', 'main', repo])
39 this.git(['config', 'user.email', 't@example.com'], repo)
40 this.git(['config', 'user.name', 'Test'], repo)
41 return repo
42 }
43
44 commit(workRepo: string, file: string, content: string): string {
45 execFileSync('bash', ['-c', `printf %s ${JSON.stringify(content)} > ${JSON.stringify(path.join(workRepo, file))}`])
46 this.git(['add', '.'], workRepo)
47 this.git(['commit', '-q', '-m', `add ${file}`], workRepo)
48 return this.git(['rev-parse', 'HEAD'], workRepo)
49 }
50
51 pushTo(workRepo: string, bareRepo: string, refspec: string): void {
52 this.git(['push', '-q', bareRepo, refspec], workRepo)
53 }
54
55 revParse(repo: string, ref: string): string {
56 return this.git(['rev-parse', ref], repo)
57 }
58
59 cleanup(): void {
60 rmSync(this.dir, { recursive: true, force: true })
61 }
62}
63
64/**
65 * Stand in for GitHub's `git-upload-pack` HTTP endpoint by running the binary
66 * in `--stateless-rpc` mode against a local bare repo. Mirrors the request /
67 * response framing the real endpoint uses, so `upload-pack.ts` can be pointed
68 * at it through a patched `fetch`.
69 */
70export function fakeGithubFetch(repos: Map<string, string>) {
71 return async function fetchImpl(input: string | URL, init?: RequestInit): Promise<Response> {
72 const url = typeof input === 'string' ? input : input.toString()
73 const match = url.match(/github\.com\/(.+?)\.git\/(info\/refs|git-upload-pack)/)
74 if (!match) throw new Error(`fakeGithubFetch: unexpected url ${url}`)
75 const repoPath = repos.get(match[1]!)
76 if (!repoPath) return new Response(null, { status: 404, statusText: 'Not Found' })
77
78 if (match[2] === 'info/refs') {
79 const adv = execFileSync('git-upload-pack', ['--stateless-rpc', '--advertise-refs', repoPath])
80 // The HTTP transport prepends the service banner + flush; the binary does not.
81 const banner = Buffer.concat([
82 encodePktLine('# service=git-upload-pack\n'),
83 flushPkt,
84 ])
85 return new Response(new Uint8Array(Buffer.concat([banner, adv])), { status: 200 })
86 }
87
88 const reqBody = Buffer.from(await new Response(init!.body as BodyInit).arrayBuffer())
89 const proc = spawnSync('git-upload-pack', ['--stateless-rpc', repoPath], { input: reqBody, maxBuffer: 1 << 30 })
90 if (proc.status !== 0) {
91 throw new Error(`git-upload-pack exited ${proc.status}: ${proc.stderr.toString()}`)
92 }
93 return new Response(new Uint8Array(proc.stdout), { status: 200 })
94 }
95}
96
97const STDERR_CAP = 16 * 1024
98
99/**
100 * A `ReceivePackFactory` that spawns the real `git-receive-pack` binary
101 * against a local bare repo, bypassing ssh. The stdio protocol is identical
102 * to what the knot speaks.
103 */
104export function localReceivePackFactory(bareRepo: string): ReceivePackFactory {
105 return () => {
106 const child: ChildProcessWithoutNullStreams = spawn('git-receive-pack', [bareRepo], {
107 stdio: ['pipe', 'pipe', 'pipe'],
108 })
109 let stderrBuf = Buffer.alloc(0)
110 child.stderr.on('data', (chunk: Buffer) => {
111 stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP)
112 })
113 const done = new Promise<number | null>(resolve => child.on('close', code => resolve(code)))
114 return {
115 stdin: child.stdin,
116 stdout: child.stdout,
117 stderr: () => stderrBuf.toString('utf8'),
118 kill: () => child.kill('SIGKILL'),
119 done,
120 }
121 }
122}