mirror your GitHub repos to tangled.org automatically
1import { execFileSync } from 'node:child_process'
2import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3import { RemoteRejectedError } from '../../server/utils/git-wire/errors'
4import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack'
5import { ZERO_SHA } from '../../server/utils/git-wire/refs'
6import { fakeGithubFetch, GitFixture, localReceivePackFactory } from '../utils/git-wire'
7import { fetchPack } from '../../server/utils/git-wire/upload-pack'
8
9async function* fromBuffer(b: Buffer): AsyncGenerator<Buffer> {
10 yield b
11}
12
13async function push(factory: ReturnType<typeof localReceivePackFactory>, updates: Parameters<ReceivePackSession['push']>[0], pack: AsyncIterable<Buffer> | null) {
14 const session = await ReceivePackSession.open(factory)
15 await session.push(updates, pack)
16}
17
18async function drain(gen: AsyncGenerator<Buffer>): Promise<Buffer> {
19 const parts: Buffer[] = []
20 for await (const c of gen) parts.push(c)
21 return Buffer.concat(parts)
22}
23
24describe('receive-pack (against real git-receive-pack)', () => {
25 let fx: GitFixture
26 let realFetch: typeof globalThis.fetch
27
28 beforeEach(() => {
29 fx = new GitFixture()
30 realFetch = globalThis.fetch
31 })
32
33 afterEach(() => {
34 globalThis.fetch = realFetch
35 fx.cleanup()
36 })
37
38 /** Build a pack on disk for `want` and return it as a single buffer. */
39 async function packFor(ghBare: string, want: string, haves: string[]): Promise<Buffer> {
40 globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', ghBare]])) as unknown as typeof globalThis.fetch
41 const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want, haves, maxBytes: 1 << 30 })
42 return drain(pack)
43 }
44
45 it('pushes a new ref into an empty knot repo', async () => {
46 const gh = fx.initBare('gh.git')
47 const work = fx.initWork('work')
48 const sha = fx.commit(work, 'a.txt', 'hello')
49 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
50 const knot = fx.initBare('knot.git')
51
52 const pack = await packFor(gh, sha, [])
53 await push(
54 localReceivePackFactory(knot),
55 [{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }],
56 fromBuffer(pack),
57 )
58
59 expect(fx.revParse(knot, 'refs/heads/main')).toBe(sha)
60 })
61
62 it('fast-forwards an existing ref with a thin incremental pack', async () => {
63 const gh = fx.initBare('gh.git')
64 const work = fx.initWork('work')
65 const first = fx.commit(work, 'a.txt', 'one')
66 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
67 const knot = fx.initBare('knot.git')
68 await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: first }], fromBuffer(await packFor(gh, first, [])))
69
70 const second = fx.commit(work, 'b.txt', 'two')
71 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
72 await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: first, next: second }], fromBuffer(await packFor(gh, second, [first])))
73
74 expect(fx.revParse(knot, 'refs/heads/main')).toBe(second)
75 })
76
77 it('rejects a stale compare-and-swap as stale-old-sha', async () => {
78 const gh = fx.initBare('gh.git')
79 const work = fx.initWork('work')
80 const first = fx.commit(work, 'a.txt', 'one')
81 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
82 const knot = fx.initBare('knot.git')
83 await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: first }], fromBuffer(await packFor(gh, first, [])))
84
85 const second = fx.commit(work, 'b.txt', 'two')
86 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
87
88 // Claim the knot is still empty when it actually points at `first`.
89 await expect(
90 push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: second }], fromBuffer(await packFor(gh, second, [first]))),
91 ).rejects.toMatchObject({ constructor: RemoteRejectedError, reason: 'stale-old-sha' })
92 })
93
94 it('deletes a ref with no pack', async () => {
95 const gh = fx.initBare('gh.git')
96 const work = fx.initWork('work')
97 const sha = fx.commit(work, 'a.txt', 'hello')
98 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
99 const knot = fx.initBare('knot.git')
100 await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }], fromBuffer(await packFor(gh, sha, [])))
101
102 await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: sha, next: ZERO_SHA }], null)
103
104 expect(() => execFileSync('git', ['rev-parse', 'refs/heads/main'], { cwd: knot })).toThrow(/unknown revision|ambiguous argument|fatal/)
105 })
106
107 it('kills a stalled session once the watchdog fires', async () => {
108 // A factory whose child accepts the connection but never advertises: the
109 // open() read would block forever without the watchdog.
110 const stalled = () => {
111 let resolveDone: (code: number | null) => void
112 const done = new Promise<number | null>(r => { resolveDone = r })
113 // eslint-disable-next-line require-yield -- models a stalled stream that blocks until killed and never emits
114 async function* neverYields(): AsyncGenerator<Buffer> {
115 await done
116 }
117 return {
118 stdin: { write: (_d: unknown, cb?: (e?: Error) => void) => cb?.(), end: () => {} } as unknown as NodeJS.WritableStream,
119 stdout: neverYields(),
120 stderr: () => '',
121 kill: () => resolveDone(null),
122 done,
123 }
124 }
125 await expect(ReceivePackSession.open(stalled, 50)).rejects.toThrow(/end of stream|advertisement/)
126 })
127
128 it('pushes an annotated tag', async () => {
129 const gh = fx.initBare('gh.git')
130 const work = fx.initWork('work')
131 fx.commit(work, 'a.txt', 'hello')
132 fx.pushTo(work, gh, 'HEAD:refs/heads/main')
133 fx.git(['tag', '-a', 'v1', '-m', 'release'], work)
134 const tagSha = fx.git(['rev-parse', 'refs/tags/v1'], work)
135 fx.pushTo(work, gh, 'refs/tags/v1:refs/tags/v1')
136 const knot = fx.initBare('knot.git')
137
138 await push(
139 localReceivePackFactory(knot),
140 [{ ref: 'refs/tags/v1', old: ZERO_SHA, next: tagSha }],
141 fromBuffer(await packFor(gh, tagSha, [])),
142 )
143
144 expect(fx.revParse(knot, 'refs/tags/v1')).toBe(tagSha)
145 expect(fx.git(['cat-file', '-t', 'refs/tags/v1'], knot)).toBe('tag')
146 })
147})