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 return repo
32 }
33
34 /** Create a non-bare work repo with one commit on `main`, return its path. */
35 initWork(name: string): string {
36 const repo = path.join(this.dir, name)
37 this.git(['init', '-q', '-b', 'main', repo])
38 this.git(['config', 'user.email', 't@example.com'], repo)
39 this.git(['config', 'user.name', 'Test'], repo)
40 return repo
41 }
42
43 commit(workRepo: string, file: string, content: string): string {
44 execFileSync('bash', ['-c', `printf %s ${JSON.stringify(content)} > ${JSON.stringify(path.join(workRepo, file))}`])
45 this.git(['add', '.'], workRepo)
46 this.git(['commit', '-q', '-m', `add ${file}`], workRepo)
47 return this.git(['rev-parse', 'HEAD'], workRepo)
48 }
49
50 pushTo(workRepo: string, bareRepo: string, refspec: string): void {
51 this.git(['push', '-q', bareRepo, refspec], workRepo)
52 }
53
54 revParse(repo: string, ref: string): string {
55 return this.git(['rev-parse', ref], repo)
56 }
57
58 cleanup(): void {
59 rmSync(this.dir, { recursive: true, force: true })
60 }
61}
62
63/**
64 * Stand in for GitHub's `git-upload-pack` HTTP endpoint by running the binary
65 * in `--stateless-rpc` mode against a local bare repo. Mirrors the request /
66 * response framing the real endpoint uses, so `upload-pack.ts` can be pointed
67 * at it through a patched `fetch`.
68 */
69export function fakeGithubFetch(repos: Map<string, string>) {
70 return async function fetchImpl(input: string | URL, init?: RequestInit): Promise<Response> {
71 const url = typeof input === 'string' ? input : input.toString()
72 const match = url.match(/github\.com\/(.+?)\.git\/(info\/refs|git-upload-pack)/)
73 if (!match) throw new Error(`fakeGithubFetch: unexpected url ${url}`)
74 const repoPath = repos.get(match[1])
75 if (!repoPath) return new Response(null, { status: 404, statusText: 'Not Found' })
76
77 if (match[2] === 'info/refs') {
78 const adv = execFileSync('git-upload-pack', ['--stateless-rpc', '--advertise-refs', repoPath])
79 // The HTTP transport prepends the service banner + flush; the binary does not.
80 const banner = Buffer.concat([
81 encodePktLine('# service=git-upload-pack\n'),
82 flushPkt,
83 ])
84 return new Response(new Uint8Array(Buffer.concat([banner, adv])), { status: 200 })
85 }
86
87 const reqBody = Buffer.from(await new Response(init!.body as BodyInit).arrayBuffer())
88 const proc = spawnSync('git-upload-pack', ['--stateless-rpc', repoPath], { input: reqBody, maxBuffer: 1 << 30 })
89 if (proc.status !== 0) {
90 throw new Error(`git-upload-pack exited ${proc.status}: ${proc.stderr.toString()}`)
91 }
92 return new Response(new Uint8Array(proc.stdout), { status: 200 })
93 }
94}
95
96const STDERR_CAP = 16 * 1024
97
98/**
99 * A `ReceivePackFactory` that spawns the real `git-receive-pack` binary
100 * against a local bare repo, bypassing ssh. The stdio protocol is identical
101 * to what the knot speaks.
102 */
103export function localReceivePackFactory(bareRepo: string): ReceivePackFactory {
104 return () => {
105 const child: ChildProcessWithoutNullStreams = spawn('git-receive-pack', [bareRepo], {
106 stdio: ['pipe', 'pipe', 'pipe'],
107 })
108 let stderrBuf = Buffer.alloc(0)
109 child.stderr.on('data', (chunk: Buffer) => {
110 stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP)
111 })
112 const done = new Promise<number | null>(resolve => child.on('close', code => resolve(code)))
113 return {
114 stdin: child.stdin,
115 stdout: child.stdout,
116 stderr: () => stderrBuf.toString('utf8'),
117 kill: () => child.kill('SIGKILL'),
118 done,
119 }
120 }
121}