1/**
2 * Protocol-v0 ref advertisement builders (DESIGN §2.3, §4).
3 *
4 * The smart-HTTP advertisement body is two pkt-line streams:
5 *
6 * ```
7 * 001e# service=git-upload-pack\n
8 * 0000
9 * <oid> HEAD\0<capabilities>\n ← first ref carries caps after a NUL
10 * <oid> refs/heads/main\n
11 * <oid> refs/tags/v1\n
12 * <peeled> refs/tags/v1^{}\n ← annotated tags peeled
13 * 0000
14 * ```
15 *
16 * Empty repo: a single `<zero-id> capabilities^{}\0<caps>` line. The response
17 * must carry `Content-Type: application/x-git-{service}-advertisement` and
18 * `Cache-Control: no-cache` (the client validates the first five bytes match
19 * `^[0-9a-f]{4}#`).
20 */
21import { concatBytes, utf8Encode, ZERO_OID } from "./ObjectCodec.ts";
22import { flushPkt, pktLine } from "./Pkt.ts";
23import type { RefRecord, RefsSnapshot } from "./Store.ts";
24
25/** The agent string advertised by both services. */
26export const AGENT = "git-service/1";
27
28/** The two smart-HTTP services. */
29export type GitService = "git-upload-pack" | "git-receive-pack";
30
31/**
32 * The v0 upload-pack capability set (DESIGN §2.3): stateless
33 * `multi_ack_detailed` + `no-done` negotiation, side-band-64k muxing, shallow
34 * (`deepen <n>` only), and the default-branch symref.
35 */
36export const uploadPackCapabilities = (defaultBranch: string): string =>
37 `multi_ack_detailed no-done side-band-64k shallow ofs-delta agent=${AGENT} symref=HEAD:refs/heads/${defaultBranch} object-format=sha1`;
38
39/**
40 * The v0 receive-pack capability set (DESIGN §2.3): per-ref report-status
41 * (v1 and v2 — v2 is implemented identically, with no `option` lines, which
42 * is valid), ref deletion, atomic pushes, and OFS_DELTA in pushed packs.
43 */
44export const receivePackCapabilities = (): string =>
45 `report-status report-status-v2 delete-refs side-band-64k atomic ofs-delta object-format=sha1 agent=${AGENT}`;
46
47/**
48 * The `Content-Type` of a `GET info/refs?service=` advertisement response.
49 */
50export const advertisementContentType = (service: GitService): string =>
51 `application/x-${service}-advertisement`;
52
53/**
54 * The `Content-Type` of a `POST /git-{upload,receive}-pack` result response.
55 */
56export const resultContentType = (service: GitService): string =>
57 `application/x-${service}-result`;
58
59/**
60 * Builds the `# service=` prelude: one pkt-line plus a flush. Sent before the
61 * ref list on `info/refs` for both services.
62 */
63export const servicePrelude = (service: GitService): Uint8Array =>
64 concatBytes([pktLine(utf8Encode(`# service=${service}\n`)), flushPkt]);
65
66/** One advertised `<oid> <name>` record (already flattened, incl. `^{}`). */
67interface AdvertisedRecord {
68 readonly oid: string;
69 readonly name: string;
70}
71
72const flattenRefs = (
73 refs: ReadonlyArray<RefRecord>,
74): Array<AdvertisedRecord> => {
75 const sorted = [...refs].sort((a, b) =>
76 a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
77 );
78 const records: Array<AdvertisedRecord> = [];
79 for (const ref of sorted) {
80 records.push({ oid: ref.oid, name: ref.name });
81 if (ref.peeled !== undefined) {
82 records.push({ oid: ref.peeled, name: `${ref.name}^{}` });
83 }
84 }
85 return records;
86};
87
88/**
89 * Encodes the ref list section: first record carries the capability list
90 * after a NUL; an empty list becomes the zero-id `capabilities^{}` line;
91 * terminated by a flush.
92 */
93const refListSection = (
94 records: ReadonlyArray<AdvertisedRecord>,
95 capabilities: string,
96): Uint8Array => {
97 const lines: Array<Uint8Array> = [];
98 if (records.length === 0) {
99 lines.push(
100 pktLine(utf8Encode(`${ZERO_OID} capabilities^{}\0${capabilities}\n`)),
101 );
102 } else {
103 const first = records[0]!;
104 lines.push(
105 pktLine(utf8Encode(`${first.oid} ${first.name}\0${capabilities}\n`)),
106 );
107 for (let i = 1; i < records.length; i++) {
108 const record = records[i]!;
109 lines.push(pktLine(utf8Encode(`${record.oid} ${record.name}\n`)));
110 }
111 }
112 lines.push(flushPkt);
113 return concatBytes(lines);
114};
115
116/**
117 * Builds the complete `info/refs?service=git-upload-pack` advertisement body:
118 * prelude, then `HEAD` first (its oid = the default branch tip, when that
119 * branch exists) with the capability line, then refs sorted by name with
120 * peeled `^{}` lines for annotated tags. An empty repo advertises the
121 * zero-id `capabilities^{}` form.
122 */
123export const uploadPackAdvertisement = (snapshot: RefsSnapshot): Uint8Array => {
124 const records = flattenRefs(snapshot.refs);
125 const head = snapshot.refs.find(
126 (ref) => ref.name === `refs/heads/${snapshot.defaultBranch}`,
127 );
128 const withHead =
129 head === undefined
130 ? records
131 : [{ oid: head.oid, name: "HEAD" }, ...records];
132 return concatBytes([
133 servicePrelude("git-upload-pack"),
134 refListSection(withHead, uploadPackCapabilities(snapshot.defaultBranch)),
135 ]);
136};
137
138/**
139 * Builds the complete `info/refs?service=git-receive-pack` advertisement
140 * body. `HEAD` is not advertised (it is not pushable); refs are sorted with
141 * peeled lines; an empty repo advertises the zero-id `capabilities^{}` form
142 * (which is how the first-ever push learns the capability set).
143 */
144export const receivePackAdvertisement = (snapshot: RefsSnapshot): Uint8Array =>
145 concatBytes([
146 servicePrelude("git-receive-pack"),
147 refListSection(flattenRefs(snapshot.refs), receivePackCapabilities()),
148 ]);
149
150/**
151 * Builds the advertisement for either service.
152 */
153export const advertisement = (
154 service: GitService,
155 snapshot: RefsSnapshot,
156): Uint8Array =>
157 service === "git-upload-pack"
158 ? uploadPackAdvertisement(snapshot)
159 : receivePackAdvertisement(snapshot);