git-fs-s3 0.15.0 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -1,914 +1,970 @@
1
- import {
2
- createSemaphore,
3
- encodePack,
4
- hashGitObject,
5
- listBranches,
6
- listLooseRefNames,
7
- packBaseName,
8
- resolveLooseRefFast,
9
- storePack
10
- } from "./chunk-IAKZRWBZ.js";
11
- import {
12
- OBJ_OFS_DELTA,
13
- OBJ_REF_DELTA,
14
- OID_BYTES,
15
- concat,
16
- decodeAscii,
17
- decodeUtf8,
18
- encodeEntryHeader,
19
- encodeUtf8,
20
- fromHex,
21
- isSafeFullRefName,
22
- packFileName,
23
- parsePackDirectory,
24
- rawFs,
25
- readEntryHeader,
26
- readOfsDeltaOffset,
27
- readPackIndex,
28
- runStep,
29
- sha1,
30
- toHex
31
- } from "./chunk-OKBQISUC.js";
32
-
33
- // src/http/info-refs.ts
1
+ import { B as fromHex, F as concat, G as sha1, I as decodeAscii, L as decodeUtf8, N as isAbsentError, P as isNotFoundError, f as readEntryHeader, h as runStep, l as readPackIndex, m as rawFs, p as readOfsDeltaOffset, q as toHex, r as isSafeFullRefName, u as encodeEntryHeader, v as packFileName, y as parsePackDirectory, z as encodeUtf8 } from "./refs-Cb_NfKWI.js";
2
+ import { a as storePack, d as listLooseRefNames, i as packBaseName, n as encodePack, p as resolveLooseRefFast, r as hashGitObject, t as createSemaphore, u as listBranches } from "./semaphore-BviNoQuJ.js";
34
3
  import git from "isomorphic-git";
35
-
36
- // src/http/pkt-line.ts
37
- var FLUSH = new Uint8Array([
38
- 48,
39
- 48,
40
- 48,
41
- 48
4
+ //#region src/http/pkt-line.ts
5
+ /**
6
+ * Git pkt-line framing (smart HTTP protocol wire format).
7
+ *
8
+ * Edge-compatible: uses Uint8Array instead of Buffer.
9
+ */
10
+ /** Flush packet: exactly four zero bytes. */
11
+ const FLUSH = new Uint8Array([
12
+ 48,
13
+ 48,
14
+ 48,
15
+ 48
42
16
  ]);
43
- var PKT_LINE_MAX_BODY = 65535 - 4;
17
+ /**
18
+ * A pkt-line length prefix is four hex digits, so 0xffff (65535) bytes total
19
+ * is the most a frame can describe — a body of 65531. Callers with longer
20
+ * payloads must chunk them (see {@link sideBandPackfile}); framing one here
21
+ * would emit a five-digit prefix no client can parse.
22
+ */
23
+ const PKT_LINE_MAX_BODY = 65531;
44
24
  function assertPktLineFits(bodyLength) {
45
- if (bodyLength > PKT_LINE_MAX_BODY) {
46
- throw new Error(
47
- `pkt-line body of ${bodyLength} bytes exceeds the protocol limit of ${PKT_LINE_MAX_BODY} bytes (0xffff total, including the 4-hex-digit length prefix)`
48
- );
49
- }
25
+ if (bodyLength > PKT_LINE_MAX_BODY) throw new Error(`pkt-line body of ${bodyLength} bytes exceeds the protocol limit of ${PKT_LINE_MAX_BODY} bytes (0xffff total, including the 4-hex-digit length prefix)`);
50
26
  }
27
+ /**
28
+ * Frame a UTF-8 string as one pkt-line. The 65531-byte body limit is a
29
+ * property of pkt-line framing, not of this function: the length prefix is
30
+ * fixed at four hex digits, so a longer frame is unparseable rather than
31
+ * merely unusual.
32
+ */
51
33
  function pktLine(data) {
52
- const body = encodeUtf8(data);
53
- assertPktLineFits(body.length);
54
- const len = (body.length + 4).toString(16).padStart(4, "0");
55
- return concat(encodeUtf8(len), body);
34
+ const body = encodeUtf8(data);
35
+ assertPktLineFits(body.length);
36
+ const len = (body.length + 4).toString(16).padStart(4, "0");
37
+ return concat(encodeUtf8(len), body);
56
38
  }
39
+ /**
40
+ * Frame raw bytes as one pkt-line. Throws above the 65531-byte body limit of
41
+ * the framing itself (see {@link pktLine}) instead of emitting a malformed
42
+ * frame.
43
+ */
57
44
  function pktLineBuffer(body) {
58
- assertPktLineFits(body.length);
59
- const len = (body.length + 4).toString(16).padStart(4, "0");
60
- return concat(encodeUtf8(len), body);
45
+ assertPktLineFits(body.length);
46
+ const len = (body.length + 4).toString(16).padStart(4, "0");
47
+ return concat(encodeUtf8(len), body);
61
48
  }
49
+ /** Decode pkt-lines to strings; `null` marks a flush-pkt. Stops on garbage. */
62
50
  function parsePktLines(buf) {
63
- const lines = [];
64
- let pos = 0;
65
- while (pos + 4 <= buf.length) {
66
- const len = Number.parseInt(decodeAscii(buf.subarray(pos, pos + 4)), 16);
67
- if (len === 0) {
68
- lines.push(null);
69
- pos += 4;
70
- } else if (len >= 4) {
71
- lines.push(decodeUtf8(buf.subarray(pos + 4, pos + len)));
72
- pos += len;
73
- } else {
74
- break;
75
- }
76
- }
77
- return lines;
51
+ const lines = [];
52
+ let pos = 0;
53
+ while (pos + 4 <= buf.length) {
54
+ const len = Number.parseInt(decodeAscii(buf.subarray(pos, pos + 4)), 16);
55
+ if (len === 0) {
56
+ lines.push(null);
57
+ pos += 4;
58
+ } else if (len >= 4) {
59
+ lines.push(decodeUtf8(buf.subarray(pos + 4, pos + len)));
60
+ pos += len;
61
+ } else break;
62
+ }
63
+ return lines;
78
64
  }
79
- var SIDE_BAND_MAX_CHUNK = 65515;
65
+ /**
66
+ * Per the git protocol, once side-band-64k has been negotiated, packfile
67
+ * bytes in the upload-pack response must be chunked into pkt-lines each
68
+ * prefixed with a control byte (0x01 = packfile data), terminated by a
69
+ * flush-pkt. Without this, clients that don't special-case "no side-band" —
70
+ * e.g. isomorphic-git's GitSideBand.demux, which always treats the response
71
+ * as side-band-framed — misparse the raw packfile bytes as bogus pkt-line
72
+ * length headers and spin forever. (Native `git` tolerates a raw unframed
73
+ * stream when side-band isn't negotiated, so this only surfaces with
74
+ * isomorphic-git as the HTTP client.)
75
+ */
76
+ const SIDE_BAND_MAX_CHUNK = 65515;
80
77
  function sideBandPackfile(packData) {
81
- const parts = [];
82
- for (let offset = 0; offset < packData.length; offset += SIDE_BAND_MAX_CHUNK) {
83
- const chunk = packData.subarray(offset, offset + SIDE_BAND_MAX_CHUNK);
84
- parts.push(pktLineBuffer(concat(new Uint8Array([1]), chunk)));
85
- }
86
- parts.push(FLUSH);
87
- return concat(...parts);
78
+ const parts = [];
79
+ for (let offset = 0; offset < packData.length; offset += SIDE_BAND_MAX_CHUNK) {
80
+ const chunk = packData.subarray(offset, offset + SIDE_BAND_MAX_CHUNK);
81
+ parts.push(pktLineBuffer(concat(new Uint8Array([1]), chunk)));
82
+ }
83
+ parts.push(FLUSH);
84
+ return concat(...parts);
88
85
  }
89
-
90
- // src/http/info-refs.ts
86
+ //#endregion
87
+ //#region src/http/info-refs.ts
88
+ /** All refs with resolved oids, plus the symref HEAD should advertise. */
91
89
  async function listAllRefs(repo, defaultBranch) {
92
- const [branches, tagNames] = await Promise.all([
93
- listBranches(repo),
94
- listLooseRefNames(repo, "refs/tags").then(
95
- (names) => names ?? git.listTags(repo)
96
- )
97
- ]);
98
- const tagRefs = await Promise.all(
99
- tagNames.map(async (tag) => {
100
- const name = `refs/tags/${tag}`;
101
- return resolveLooseRefFast(repo, name).then((oid) => ({ name, oid })).catch(() => null);
102
- })
103
- );
104
- const branchRefs = branches.map((b) => ({
105
- name: `refs/heads/${b.name}`,
106
- oid: b.commit
107
- }));
108
- const defaultRef = branches.find((b) => b.isDefault);
109
- const current = defaultRef ? null : await git.currentBranch({ ...repo, fullname: true }).catch(() => void 0);
110
- const headSymref = defaultRef ? `refs/heads/${defaultRef.name}` : current ?? `refs/heads/${defaultBranch}`;
111
- const headOid = branchRefs.find((r) => r.name === headSymref)?.oid ?? (defaultRef || current ? null : await git.resolveRef({ ...repo, ref: "HEAD" }).catch(() => null));
112
- const refs = [];
113
- if (headOid) refs.push({ name: "HEAD", oid: headOid });
114
- refs.push(...branchRefs);
115
- for (const r of tagRefs) if (r) refs.push(r);
116
- return { refs, headSymref };
90
+ const [branches, tagNames] = await Promise.all([listBranches(repo), listLooseRefNames(repo, "refs/tags").then((names) => names ?? git.listTags(repo))]);
91
+ const tagRefs = await Promise.all(tagNames.map(async (tag) => {
92
+ const name = `refs/tags/${tag}`;
93
+ return resolveLooseRefFast(repo, name).then((oid) => ({
94
+ name,
95
+ oid
96
+ })).catch((error) => {
97
+ if (isAbsentError(error)) return null;
98
+ throw error;
99
+ });
100
+ }));
101
+ const branchRefs = branches.map((b) => ({
102
+ name: `refs/heads/${b.name}`,
103
+ oid: b.commit
104
+ }));
105
+ const defaultRef = branches.find((b) => b.isDefault);
106
+ const current = defaultRef ? null : await git.currentBranch({
107
+ ...repo,
108
+ fullname: true
109
+ }).catch(() => void 0);
110
+ const headSymref = defaultRef ? `refs/heads/${defaultRef.name}` : current ?? `refs/heads/${defaultBranch}`;
111
+ const headOid = branchRefs.find((r) => r.name === headSymref)?.oid ?? (defaultRef || current ? null : await git.resolveRef({
112
+ ...repo,
113
+ ref: "HEAD"
114
+ }).catch(() => null));
115
+ const refs = [];
116
+ if (headOid) refs.push({
117
+ name: "HEAD",
118
+ oid: headOid
119
+ });
120
+ refs.push(...branchRefs);
121
+ for (const r of tagRefs) if (r) refs.push(r);
122
+ return {
123
+ refs,
124
+ headSymref
125
+ };
117
126
  }
127
+ /**
128
+ * `GET …/info/refs?service=…` — the ref advertisement. Authentication and
129
+ * authorization are the caller's job before invoking this.
130
+ *
131
+ * Capabilities advertised match what the sibling handlers implement:
132
+ * side-band-64k (honored by handleUploadPack's response framing — clients
133
+ * like isomorphic-git unconditionally expect it), tip/reachable sha1 wants,
134
+ * delete-refs and report-status for pushes.
135
+ */
118
136
  async function handleInfoRefs(repo, options, hooks) {
119
- const { service } = options;
120
- const agent = options.agent ?? "git-fs-s3";
121
- const { refs, headSymref } = await runStep(
122
- hooks,
123
- "listAllRefs",
124
- () => listAllRefs(repo, options.defaultBranch)
125
- );
126
- const isUpload = service === "git-upload-pack";
127
- const caps = isUpload ? `no-progress side-band-64k symref=HEAD:${headSymref} allow-tip-sha1-in-want allow-reachable-sha1-in-want agent=${agent}` : `report-status delete-refs side-band-64k agent=${agent}`;
128
- const parts = [pktLine(`# service=${service}
129
- `), FLUSH];
130
- if (refs.length === 0) {
131
- parts.push(
132
- pktLine(
133
- `0000000000000000000000000000000000000000 capabilities^{}\0${caps}
134
- `
135
- )
136
- );
137
- } else {
138
- let first = true;
139
- for (const { name, oid } of refs) {
140
- parts.push(
141
- pktLine(first ? `${oid} ${name}\0${caps}
142
- ` : `${oid} ${name}
143
- `)
144
- );
145
- first = false;
146
- }
147
- }
148
- parts.push(FLUSH);
149
- return {
150
- status: 200,
151
- headers: {
152
- "Content-Type": `application/x-${service}-advertisement`,
153
- "Cache-Control": "no-cache"
154
- },
155
- body: concat(...parts)
156
- };
137
+ const { service } = options;
138
+ const agent = options.agent ?? "git-fs-s3";
139
+ const { refs, headSymref } = await runStep(hooks, "listAllRefs", () => listAllRefs(repo, options.defaultBranch));
140
+ const caps = service === "git-upload-pack" ? `no-progress side-band-64k symref=HEAD:${headSymref} allow-tip-sha1-in-want allow-reachable-sha1-in-want agent=${agent}` : `report-status delete-refs side-band-64k agent=${agent}`;
141
+ const parts = [pktLine(`# service=${service}\n`), FLUSH];
142
+ if (refs.length === 0) parts.push(pktLine(`0000000000000000000000000000000000000000 capabilities^{}\0${caps}\n`));
143
+ else {
144
+ let first = true;
145
+ for (const { name, oid } of refs) {
146
+ parts.push(pktLine(first ? `${oid} ${name}\0${caps}\n` : `${oid} ${name}\n`));
147
+ first = false;
148
+ }
149
+ }
150
+ parts.push(FLUSH);
151
+ return {
152
+ status: 200,
153
+ headers: {
154
+ "Content-Type": `application/x-${service}-advertisement`,
155
+ "Cache-Control": "no-cache"
156
+ },
157
+ body: concat(...parts)
158
+ };
157
159
  }
158
-
159
- // src/http/reachability.ts
160
- import git2 from "isomorphic-git";
161
- var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
162
- var MISSING_OBJECT_RETRY_DELAY_MS = 200;
163
- var MAX_CONCURRENT_READS = 16;
160
+ //#endregion
161
+ //#region src/http/reachability.ts
162
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
163
+ /**
164
+ * A read landing in the gap between a repack's new consolidated pack being
165
+ * uploaded and the stale-pack cleanup finishing can transiently miss an
166
+ * object that is not actually lost — it exists in the new pack the whole
167
+ * time; the reader's cached pack listing was just taken mid-transition. One
168
+ * retry after a short delay is enough to observe the consistent listing.
169
+ */
170
+ const MISSING_OBJECT_RETRY_DELAY_MS = 200;
171
+ /**
172
+ * Walk the object graph from `startOids`, returning every reachable oid.
173
+ * Concurrent traversal paths are deduplicated promise-per-oid; a missing
174
+ * object is retried once, then reported through `hooks.onWarn` and
175
+ * reflected in `complete: false`.
176
+ *
177
+ * `haveOids` bounds the walk: any oid in this set is assumed already
178
+ * verified (it was reachable from a ref that existed before this call) and
179
+ * is recorded in `seen` without being read or descended into. Without a
180
+ * boundary, every call re-walks the *entire* history from scratch — for
181
+ * receive-pack validation (see receive-pack.ts) that means every push pays
182
+ * for reading every object in the repo, not just the ones it introduced,
183
+ * and one transient read failure anywhere in that whole history rejects an
184
+ * otherwise-valid push.
185
+ *
186
+ * Commits, trees and tags are read once each (parsed). Blobs have no
187
+ * children, so `verifyBlobs: false` records a blob from its tree entry
188
+ * without reading (and inflating) it — for callers that read every blob
189
+ * themselves afterwards anyway, like upload-pack's `packObjects`. Leave it
190
+ * true wherever the walk *is* the existence check (receive-pack, repack).
191
+ *
192
+ * Object reads are bounded by one shared {@link MAX_CONCURRENT_READS}
193
+ * semaphore for the whole walk, held only for the read itself — not per
194
+ * level, since the walk recurses and a per-level limit would still multiply
195
+ * with depth, and not across the child recursion, which would deadlock a wide
196
+ * tree once every permit belonged to a parent awaiting its children.
197
+ */
164
198
  async function collectReachableOids(repo, startOids, hooks, haveOids, options) {
165
- const verifyBlobs = options?.verifyBlobs ?? true;
166
- const have = haveOids instanceof Set ? haveOids : new Set(haveOids ?? []);
167
- const seen = /* @__PURE__ */ new Set();
168
- let complete = true;
169
- const promises = /* @__PURE__ */ new Map();
170
- const reads = createSemaphore(MAX_CONCURRENT_READS);
171
- async function readAndVisitChildren(oid) {
172
- const obj = await reads.run(() => git2.readObject({ ...repo, oid }));
173
- seen.add(oid);
174
- const children = [];
175
- if (obj.type === "commit") {
176
- const commit = obj.object;
177
- children.push([commit.tree, "tree"]);
178
- for (const parent of commit.parent) children.push([parent, "commit"]);
179
- } else if (obj.type === "tree") {
180
- const tree = obj.object;
181
- for (const entry of tree) {
182
- if (entry.type === "commit") continue;
183
- children.push([entry.oid, entry.type]);
184
- }
185
- } else if (obj.type === "tag") {
186
- children.push([obj.object.object, void 0]);
187
- }
188
- await Promise.all(children.map(([child, type]) => visit(child, type)));
189
- }
190
- function visit(oid, type) {
191
- const existing = promises.get(oid);
192
- if (existing) return existing;
193
- if (have.has(oid) || type === "blob" && !verifyBlobs) {
194
- seen.add(oid);
195
- const p2 = Promise.resolve();
196
- promises.set(oid, p2);
197
- return p2;
198
- }
199
- const p = (async () => {
200
- try {
201
- await readAndVisitChildren(oid);
202
- } catch {
203
- try {
204
- await delay(MISSING_OBJECT_RETRY_DELAY_MS);
205
- await readAndVisitChildren(oid);
206
- } catch (err) {
207
- complete = false;
208
- hooks?.onWarn?.(`missing object ${oid}`, err);
209
- }
210
- }
211
- })();
212
- promises.set(oid, p);
213
- return p;
214
- }
215
- await Promise.all(startOids.map((oid) => visit(oid)));
216
- return { oids: Array.from(seen), complete };
199
+ const verifyBlobs = options?.verifyBlobs ?? true;
200
+ const have = haveOids instanceof Set ? haveOids : new Set(haveOids ?? []);
201
+ const seen = /* @__PURE__ */ new Set();
202
+ let complete = true;
203
+ const promises = /* @__PURE__ */ new Map();
204
+ const reads = createSemaphore(16);
205
+ async function readAndVisitChildren(oid) {
206
+ const obj = await reads.run(() => git.readObject({
207
+ ...repo,
208
+ oid
209
+ }));
210
+ seen.add(oid);
211
+ const children = [];
212
+ if (obj.type === "commit") {
213
+ const commit = obj.object;
214
+ children.push([commit.tree, "tree"]);
215
+ for (const parent of commit.parent) children.push([parent, "commit"]);
216
+ } else if (obj.type === "tree") {
217
+ const tree = obj.object;
218
+ for (const entry of tree) {
219
+ if (entry.type === "commit") continue;
220
+ children.push([entry.oid, entry.type]);
221
+ }
222
+ } else if (obj.type === "tag") children.push([obj.object.object, void 0]);
223
+ await Promise.all(children.map(([child, type]) => visit(child, type)));
224
+ }
225
+ function visit(oid, type) {
226
+ const existing = promises.get(oid);
227
+ if (existing) return existing;
228
+ if (have.has(oid) || type === "blob" && !verifyBlobs) {
229
+ seen.add(oid);
230
+ const p = Promise.resolve();
231
+ promises.set(oid, p);
232
+ return p;
233
+ }
234
+ const p = (async () => {
235
+ try {
236
+ await readAndVisitChildren(oid);
237
+ } catch {
238
+ try {
239
+ await delay(MISSING_OBJECT_RETRY_DELAY_MS);
240
+ await readAndVisitChildren(oid);
241
+ } catch (err) {
242
+ complete = false;
243
+ hooks?.onWarn?.(`missing object ${oid}`, err);
244
+ }
245
+ }
246
+ })();
247
+ promises.set(oid, p);
248
+ return p;
249
+ }
250
+ await Promise.all(startOids.map((oid) => visit(oid)));
251
+ return {
252
+ oids: Array.from(seen),
253
+ complete
254
+ };
217
255
  }
256
+ /**
257
+ * Commit-only ancestry walk: every commit reachable from `startOids`
258
+ * (annotated tags peeled), stopping at — and not descending into — commits in
259
+ * `stopAt`. Reads commit objects only, never trees or blobs, so it costs a
260
+ * fraction of {@link collectReachableOids} over the same history. Start oids
261
+ * that don't exist (or aren't commits/tags) are skipped. Returns the commits
262
+ * walked and the `stopAt` commits the walk ran into.
263
+ */
218
264
  async function collectCommitAncestry(repo, startOids, stopAt) {
219
- const commits = /* @__PURE__ */ new Set();
220
- const boundary = /* @__PURE__ */ new Set();
221
- const queued = /* @__PURE__ */ new Set();
222
- let frontier = [...new Set(startOids)];
223
- const BATCH = 50;
224
- while (frontier.length > 0) {
225
- const next = [];
226
- for (let i = 0; i < frontier.length; i += BATCH) {
227
- await Promise.all(
228
- frontier.slice(i, i + BATCH).map(async (oid) => {
229
- if (stopAt?.has(oid)) {
230
- boundary.add(oid);
231
- return;
232
- }
233
- const object = await git2.readObject({ ...repo, oid }).catch(() => null);
234
- if (object?.type === "tag") {
235
- const target = object.object.object;
236
- if (!queued.has(target)) {
237
- queued.add(target);
238
- next.push(target);
239
- }
240
- return;
241
- }
242
- if (object?.type !== "commit") return;
243
- commits.add(oid);
244
- for (const parent of object.object.parent) {
245
- if (!queued.has(parent)) {
246
- queued.add(parent);
247
- next.push(parent);
248
- }
249
- }
250
- })
251
- );
252
- }
253
- frontier = next;
254
- }
255
- return { commits, boundary };
265
+ const commits = /* @__PURE__ */ new Set();
266
+ const boundary = /* @__PURE__ */ new Set();
267
+ const queued = /* @__PURE__ */ new Set();
268
+ let frontier = [...new Set(startOids)];
269
+ const BATCH = 50;
270
+ while (frontier.length > 0) {
271
+ const next = [];
272
+ for (let i = 0; i < frontier.length; i += BATCH) await Promise.all(frontier.slice(i, i + BATCH).map(async (oid) => {
273
+ if (stopAt?.has(oid)) {
274
+ boundary.add(oid);
275
+ return;
276
+ }
277
+ const object = await git.readObject({
278
+ ...repo,
279
+ oid
280
+ }).catch(() => null);
281
+ if (object?.type === "tag") {
282
+ const target = object.object.object;
283
+ if (!queued.has(target)) {
284
+ queued.add(target);
285
+ next.push(target);
286
+ }
287
+ return;
288
+ }
289
+ if (object?.type !== "commit") return;
290
+ commits.add(oid);
291
+ for (const parent of object.object.parent) if (!queued.has(parent)) {
292
+ queued.add(parent);
293
+ next.push(parent);
294
+ }
295
+ }));
296
+ frontier = next;
297
+ }
298
+ return {
299
+ commits,
300
+ boundary
301
+ };
256
302
  }
257
-
258
- // src/http/receive-pack.ts
259
- import git4 from "isomorphic-git";
260
-
261
- // src/http/repack.ts
262
- import git3 from "isomorphic-git";
263
-
264
- // src/http/pack-reuse.ts
265
- var PACK_TRAILER_BYTES = 20;
303
+ //#endregion
304
+ //#region src/http/pack-reuse.ts
305
+ /**
306
+ * Build a packfile by copying already-compressed entries out of existing
307
+ * packs ("pack reuse"), instead of inflating and re-deflating every object.
308
+ *
309
+ * Entries keep their stored form, deltas included, so the output is as
310
+ * compact as the packs it came from. `OFS_DELTA` entries are rewritten as
311
+ * `REF_DELTA` (base named by oid), since offsets don't survive the move into
312
+ * a new pack; delta bases are always included and written before the deltas
313
+ * that use them. Formats: gitformat-pack(5).
314
+ *
315
+ * Edge-compatible: Web Crypto SHA-1 and Uint8Array only.
316
+ */
317
+ const PACK_TRAILER_BYTES = 20;
318
+ /** Locate every entry of `pack` using its `.idx`. */
266
319
  function indexPack(idx, pack) {
267
- const located = readPackIndex(idx);
268
- if (!located) return null;
269
- located.sort((a, b) => a.offset - b.offset);
270
- const entries = /* @__PURE__ */ new Map();
271
- const oidAtOffset = /* @__PURE__ */ new Map();
272
- const packEnd = pack.byteLength - PACK_TRAILER_BYTES;
273
- for (let i = 0; i < located.length; i++) {
274
- const { oid, offset } = located[i];
275
- const end = located[i + 1]?.offset ?? packEnd;
276
- entries.set(oid, { pack, start: offset, end });
277
- oidAtOffset.set(offset, oid);
278
- }
279
- return { entries, oidAtOffset };
320
+ const located = readPackIndex(idx);
321
+ if (!located) return null;
322
+ located.sort((a, b) => a.offset - b.offset);
323
+ const entries = /* @__PURE__ */ new Map();
324
+ const oidAtOffset = /* @__PURE__ */ new Map();
325
+ const packEnd = pack.byteLength - PACK_TRAILER_BYTES;
326
+ for (let i = 0; i < located.length; i++) {
327
+ const { oid, offset } = located[i];
328
+ const end = located[i + 1]?.offset ?? packEnd;
329
+ entries.set(oid, {
330
+ pack,
331
+ start: offset,
332
+ end
333
+ });
334
+ oidAtOffset.set(offset, oid);
335
+ }
336
+ return {
337
+ entries,
338
+ oidAtOffset
339
+ };
280
340
  }
341
+ /** Copy one entry, rewriting `OFS_DELTA` to `REF_DELTA`. */
281
342
  function reusableEntry(entry, indexed) {
282
- const { pack, start, end } = entry;
283
- const header = readEntryHeader(pack, start);
284
- const bodyStart = start + header.length;
285
- if (header.type === OBJ_REF_DELTA) {
286
- return {
287
- bytes: pack.subarray(start, end),
288
- baseOid: toHex(pack.subarray(bodyStart, bodyStart + OID_BYTES))
289
- };
290
- }
291
- if (header.type !== OBJ_OFS_DELTA) {
292
- return { bytes: pack.subarray(start, end), baseOid: null };
293
- }
294
- const { distance, length } = readOfsDeltaOffset(pack, bodyStart);
295
- const baseOid = indexed.oidAtOffset.get(start - distance);
296
- if (baseOid === void 0) return null;
297
- return {
298
- bytes: concat(
299
- encodeEntryHeader(OBJ_REF_DELTA, header.size),
300
- fromHex(baseOid),
301
- pack.subarray(bodyStart + length, end)
302
- ),
303
- baseOid
304
- };
343
+ const { pack, start, end } = entry;
344
+ const header = readEntryHeader(pack, start);
345
+ const bodyStart = start + header.length;
346
+ if (header.type === 7) return {
347
+ bytes: pack.subarray(start, end),
348
+ baseOid: toHex(pack.subarray(bodyStart, bodyStart + 20))
349
+ };
350
+ if (header.type !== 6) return {
351
+ bytes: pack.subarray(start, end),
352
+ baseOid: null
353
+ };
354
+ const { distance, length } = readOfsDeltaOffset(pack, bodyStart);
355
+ const baseOid = indexed.oidAtOffset.get(start - distance);
356
+ if (baseOid === void 0) return null;
357
+ return {
358
+ bytes: concat(encodeEntryHeader(7, header.size), fromHex(baseOid), pack.subarray(bodyStart + length, end)),
359
+ baseOid
360
+ };
305
361
  }
362
+ /**
363
+ * A pack holding at least `oids`, built from stored entries, or `null` when
364
+ * any of them (or a delta base they need) isn't in a readable pack — e.g. a
365
+ * loose object — so the caller should build the pack another way.
366
+ */
306
367
  async function buildPackFromExistingPacks(repo, oids) {
307
- const fsp = rawFs(repo);
308
- const packDir = `${repo.gitdir}/objects/pack`;
309
- const packs = parsePackDirectory(
310
- await fsp.readdir(packDir).catch(() => [])
311
- ).filter((file) => file.kind === "pack");
312
- const indexes = [];
313
- for (const { base } of packs) {
314
- const [pack, idx] = await Promise.all([
315
- fsp.readFile(`${packDir}/${packFileName(base, "pack")}`),
316
- fsp.readFile(`${packDir}/${packFileName(base, "idx")}`)
317
- ]).catch(() => [null, null]);
318
- if (!(pack instanceof Uint8Array) || !(idx instanceof Uint8Array)) {
319
- return null;
320
- }
321
- const indexed = indexPack(idx, pack);
322
- if (!indexed) return null;
323
- indexes.push(indexed);
324
- }
325
- const locate = (oid) => {
326
- for (const indexed of indexes) {
327
- const entry = indexed.entries.get(oid);
328
- if (entry) return reusableEntry(entry, indexed);
329
- }
330
- return null;
331
- };
332
- const ordered = [];
333
- const done = /* @__PURE__ */ new Set();
334
- const pending = /* @__PURE__ */ new Set();
335
- const visit = (oid) => {
336
- if (done.has(oid)) return true;
337
- if (pending.has(oid)) return false;
338
- const entry = locate(oid);
339
- if (!entry) return false;
340
- pending.add(oid);
341
- if (entry.baseOid !== null && !visit(entry.baseOid)) return false;
342
- pending.delete(oid);
343
- done.add(oid);
344
- ordered.push(entry.bytes);
345
- return true;
346
- };
347
- for (const oid of oids) {
348
- if (!visit(oid)) return null;
349
- }
350
- const header = new Uint8Array(12);
351
- header.set(encodeUtf8("PACK"), 0);
352
- const view = new DataView(header.buffer);
353
- view.setUint32(4, 2);
354
- view.setUint32(8, ordered.length);
355
- const body = concat(header, ...ordered);
356
- return concat(body, fromHex(await sha1(body)));
368
+ const fsp = rawFs(repo);
369
+ const packDir = `${repo.gitdir}/objects/pack`;
370
+ const packs = parsePackDirectory(await fsp.readdir(packDir).catch(() => [])).filter((file) => file.kind === "pack");
371
+ const indexes = [];
372
+ for (const { base } of packs) {
373
+ const [pack, idx] = await Promise.all([fsp.readFile(`${packDir}/${packFileName(base, "pack")}`), fsp.readFile(`${packDir}/${packFileName(base, "idx")}`)]).catch(() => [null, null]);
374
+ if (!(pack instanceof Uint8Array) || !(idx instanceof Uint8Array)) return null;
375
+ const indexed = indexPack(idx, pack);
376
+ if (!indexed) return null;
377
+ indexes.push(indexed);
378
+ }
379
+ const locate = (oid) => {
380
+ for (const indexed of indexes) {
381
+ const entry = indexed.entries.get(oid);
382
+ if (entry) return reusableEntry(entry, indexed);
383
+ }
384
+ return null;
385
+ };
386
+ const ordered = [];
387
+ const done = /* @__PURE__ */ new Set();
388
+ const pending = /* @__PURE__ */ new Set();
389
+ const visit = (oid) => {
390
+ if (done.has(oid)) return true;
391
+ if (pending.has(oid)) return false;
392
+ const entry = locate(oid);
393
+ if (!entry) return false;
394
+ pending.add(oid);
395
+ if (entry.baseOid !== null && !visit(entry.baseOid)) return false;
396
+ pending.delete(oid);
397
+ done.add(oid);
398
+ ordered.push(entry.bytes);
399
+ return true;
400
+ };
401
+ for (const oid of oids) if (!visit(oid)) return null;
402
+ const header = /* @__PURE__ */ new Uint8Array(12);
403
+ header.set(encodeUtf8("PACK"), 0);
404
+ const view = new DataView(header.buffer);
405
+ view.setUint32(4, 2);
406
+ view.setUint32(8, ordered.length);
407
+ const body = concat(header, ...ordered);
408
+ return concat(body, fromHex(await sha1(body)));
357
409
  }
358
-
359
- // src/http/repack.ts
410
+ //#endregion
411
+ //#region src/http/repack.ts
412
+ /**
413
+ * Pack consolidation: merge all packs into one, reusing stored (possibly
414
+ * deltified) entries when every object is packed.
415
+ *
416
+ * Edge-compatible: uses Web Crypto (SHA-1), CompressionStream (deflate),
417
+ * and Uint8Array throughout — no node:crypto, node:zlib, or Buffer.
418
+ */
419
+ /**
420
+ * Read every reachable object and independently re-derive its oid from the
421
+ * bytes isomorphic-git handed back, instead of trusting the oid it was asked
422
+ * for. isomorphic-git's *packed*-object read path never verifies the
423
+ * resolved content's SHA-1 against the requested oid — only the
424
+ * loose-object branch does.
425
+ */
360
426
  async function readAndVerifyObjects(repo, oids) {
361
- const objects = /* @__PURE__ */ new Map();
362
- const BATCH_SIZE = 100;
363
- for (let i = 0; i < oids.length; i += BATCH_SIZE) {
364
- const batch = oids.slice(i, i + BATCH_SIZE);
365
- const entries = await Promise.all(
366
- batch.map(async (oid) => {
367
- const { type, object } = await git3.readObject({
368
- ...repo,
369
- oid,
370
- format: "content"
371
- });
372
- const content = new Uint8Array(object);
373
- const actualOid = await hashGitObject(type, content);
374
- if (actualOid !== oid) {
375
- throw new Error(
376
- `repack: object ${oid} failed independent SHA-1 verification (recomputed ${actualOid}) \u2014 refusing to trust this read, aborting repack`
377
- );
378
- }
379
- return { oid, type, content };
380
- })
381
- );
382
- for (const { oid, type, content } of entries) {
383
- objects.set(oid, { type, content });
384
- }
385
- }
386
- return objects;
427
+ const objects = /* @__PURE__ */ new Map();
428
+ const BATCH_SIZE = 100;
429
+ for (let i = 0; i < oids.length; i += BATCH_SIZE) {
430
+ const batch = oids.slice(i, i + BATCH_SIZE);
431
+ const entries = await Promise.all(batch.map(async (oid) => {
432
+ const { type, object } = await git.readObject({
433
+ ...repo,
434
+ oid,
435
+ format: "content"
436
+ });
437
+ const content = new Uint8Array(object);
438
+ const actualOid = await hashGitObject(type, content);
439
+ if (actualOid !== oid) throw new Error(`repack: object ${oid} failed independent SHA-1 verification (recomputed ${actualOid}) — refusing to trust this read, aborting repack`);
440
+ return {
441
+ oid,
442
+ type,
443
+ content
444
+ };
445
+ }));
446
+ for (const { oid, type, content } of entries) objects.set(oid, {
447
+ type,
448
+ content
449
+ });
450
+ }
451
+ return objects;
387
452
  }
388
- var REPACK_PACK_COUNT_THRESHOLD = 4;
453
+ /**
454
+ * Default repack threshold. Consolidating is O(total repo object count) —
455
+ * so paying it on every push makes push latency grow with repo size forever.
456
+ */
457
+ const REPACK_PACK_COUNT_THRESHOLD = 4;
389
458
  async function readPackDirectory(repo) {
390
- const entries = await rawFs(repo).readdir(`${repo.gitdir}/objects/pack`).catch(() => []);
391
- return parsePackDirectory(entries);
459
+ const entries = await rawFs(repo).readdir(`${repo.gitdir}/objects/pack`).catch((error) => {
460
+ if (isAbsentError(error)) return [];
461
+ throw error;
462
+ });
463
+ return parsePackDirectory(entries);
392
464
  }
393
465
  async function countPacks(repo) {
394
- const files = await readPackDirectory(repo);
395
- return files.filter((file) => file.kind === "pack").length;
466
+ return (await readPackDirectory(repo)).filter((file) => file.kind === "pack").length;
396
467
  }
468
+ /**
469
+ * Consolidate all pack files into one, returning the gitdir-relative paths
470
+ * of the old .pack/.idx files it removed — after syncing the new pack to any
471
+ * secondary storage, delete those same paths there too.
472
+ *
473
+ * A failure rejects rather than being swallowed: whether a failed
474
+ * consolidation is fatal depends on the caller — a post-write repack must
475
+ * not fail an already-durable write, an explicit maintenance repack must
476
+ * report its failure — so the decision belongs at the call site. Leaving a
477
+ * failed run behind is safe (an unconsolidated repo still reads correctly),
478
+ * which is what lets those callers treat it as best-effort.
479
+ */
397
480
  async function repackRepository(repo, options, hooks) {
398
- const threshold = options?.threshold ?? REPACK_PACK_COUNT_THRESHOLD;
399
- const fsp = rawFs(repo);
400
- try {
401
- if (await countPacks(repo) < threshold) {
402
- return [];
403
- }
404
- const [branches, tags] = await Promise.all([
405
- git3.listBranches(repo),
406
- git3.listTags(repo)
407
- ]);
408
- const refNames = [
409
- ...branches.map((b) => `refs/heads/${b}`),
410
- ...tags.map((t) => `refs/tags/${t}`)
411
- ];
412
- const tipOids = (await Promise.all(
413
- refNames.map(
414
- (ref) => git3.resolveRef({ ...repo, ref }).catch(() => null)
415
- )
416
- )).filter((oid) => oid !== null);
417
- if (tipOids.length === 0) return [];
418
- const { oids: reachable, complete } = await collectReachableOids(
419
- repo,
420
- tipOids,
421
- hooks,
422
- void 0,
423
- { verifyBlobs: false }
424
- );
425
- if (!complete || reachable.length === 0) return [];
426
- const packDir = `${repo.gitdir}/objects/pack`;
427
- const packBuffer = await buildPackFromExistingPacks(repo, reachable) ?? await (async () => {
428
- const objects = await readAndVerifyObjects(repo, reachable);
429
- return encodePack(
430
- reachable.map((oid) => {
431
- const object = objects.get(oid);
432
- if (!object) {
433
- throw new Error(`repack: missing verified object for ${oid}`);
434
- }
435
- return object;
436
- })
437
- );
438
- })();
439
- await fsp.mkdir(packDir, { recursive: true });
440
- const newBase = packBaseName(packBuffer);
441
- const newPackFile = packFileName(newBase, "pack");
442
- const preexisting = (await readPackDirectory(repo)).some(
443
- (file) => file.base === newBase
444
- );
445
- await fsp.writeFile(`${packDir}/${newPackFile}`, packBuffer);
446
- const { oids: indexedOids } = await git3.indexPack({
447
- ...repo,
448
- dir: packDir,
449
- filepath: newPackFile
450
- });
451
- const indexed = new Set(indexedOids);
452
- if (reachable.some((oid) => !indexed.has(oid))) {
453
- if (!preexisting)
454
- await Promise.all(
455
- ["pack", "idx"].map(
456
- (kind) => fsp.unlink(`${packDir}/${packFileName(newBase, kind)}`).catch(() => {
457
- })
458
- )
459
- );
460
- throw new Error(
461
- "repack: indexed pack is missing reachable objects \u2014 aborting"
462
- );
463
- }
464
- const stale = (await readPackDirectory(repo)).filter(
465
- (file) => file.base !== newBase
466
- );
467
- const unlinkAll = (files) => Promise.all(
468
- files.map(
469
- (file) => fsp.unlink(`${packDir}/${file.name}`).catch(() => {
470
- })
471
- )
472
- );
473
- await unlinkAll(stale.filter((file) => file.kind === "idx"));
474
- await unlinkAll(stale.filter((file) => file.kind !== "idx"));
475
- return stale.map((file) => `objects/pack/${file.name}`);
476
- } catch (err) {
477
- hooks?.onWarn?.("repack failed (non-fatal)", err);
478
- return [];
479
- }
481
+ const threshold = options?.threshold ?? 4;
482
+ const fsp = rawFs(repo);
483
+ if (await countPacks(repo) < threshold) return [];
484
+ const [branches, tags] = await Promise.all([git.listBranches(repo), git.listTags(repo)]);
485
+ const refNames = [...branches.map((b) => `refs/heads/${b}`), ...tags.map((t) => `refs/tags/${t}`)];
486
+ const tipOids = (await Promise.all(refNames.map(async (ref) => {
487
+ try {
488
+ return await git.resolveRef({
489
+ ...repo,
490
+ ref
491
+ });
492
+ } catch (error) {
493
+ if (isNotFoundError(error)) return null;
494
+ throw error;
495
+ }
496
+ }))).filter((oid) => oid !== null);
497
+ if (tipOids.length === 0) return [];
498
+ const { oids: reachable, complete } = await collectReachableOids(repo, tipOids, hooks, void 0, { verifyBlobs: false });
499
+ if (!complete || reachable.length === 0) return [];
500
+ const packDir = `${repo.gitdir}/objects/pack`;
501
+ const packBuffer = await buildPackFromExistingPacks(repo, reachable) ?? await (async () => {
502
+ const objects = await readAndVerifyObjects(repo, reachable);
503
+ return encodePack(reachable.map((oid) => {
504
+ const object = objects.get(oid);
505
+ if (!object) throw new Error(`repack: missing verified object for ${oid}`);
506
+ return object;
507
+ }));
508
+ })();
509
+ await fsp.mkdir(packDir, { recursive: true });
510
+ const newBase = packBaseName(packBuffer);
511
+ const newPackFile = packFileName(newBase, "pack");
512
+ const preexisting = (await readPackDirectory(repo)).some((file) => file.base === newBase);
513
+ await fsp.writeFile(`${packDir}/${newPackFile}`, packBuffer);
514
+ const { oids: indexedOids } = await git.indexPack({
515
+ ...repo,
516
+ dir: packDir,
517
+ filepath: newPackFile
518
+ });
519
+ const indexed = new Set(indexedOids);
520
+ if (reachable.some((oid) => !indexed.has(oid))) {
521
+ if (!preexisting) await Promise.all(["pack", "idx"].map((kind) => fsp.unlink(`${packDir}/${packFileName(newBase, kind)}`).catch(() => {})));
522
+ throw new Error("repack: indexed pack is missing reachable objects — aborting");
523
+ }
524
+ const stale = (await readPackDirectory(repo)).filter((file) => file.base !== newBase);
525
+ const unlinkAll = (files) => Promise.all(files.map((file) => fsp.unlink(`${packDir}/${file.name}`).catch(() => {})));
526
+ await unlinkAll(stale.filter((file) => file.kind === "idx"));
527
+ await unlinkAll(stale.filter((file) => file.kind !== "idx"));
528
+ return stale.map((file) => `objects/pack/${file.name}`);
480
529
  }
481
-
482
- // src/http/receive-pack.ts
483
- var ZERO_OID = "0".repeat(40);
484
- var OID_RE = /^[0-9a-f]{40}$/;
530
+ //#endregion
531
+ //#region src/http/receive-pack.ts
532
+ const ZERO_OID$1 = "0".repeat(40);
533
+ const OID_RE$1 = /^[0-9a-f]{40}$/;
534
+ /**
535
+ * Split a receive-pack body: pkt-line ref commands, flush, then raw PACK.
536
+ * A command whose oids aren't 40 lowercase hex digits is dropped — it can
537
+ * never be applied, and its oids would otherwise reach object-path lookups.
538
+ */
485
539
  function parseReceivePackBody(body) {
486
- const refUpdates = [];
487
- let capabilities;
488
- let pos = 0;
489
- while (pos + 4 <= body.length) {
490
- const len = Number.parseInt(decodeAscii(body.subarray(pos, pos + 4)), 16);
491
- if (len === 0) {
492
- pos += 4;
493
- break;
494
- }
495
- if (len < 4) break;
496
- const [line, caps] = decodeUtf8(body.subarray(pos + 4, pos + len)).replace(/\n$/, "").split("\0");
497
- if (capabilities === void 0) {
498
- capabilities = (caps ?? "").split(" ").filter(Boolean);
499
- }
500
- pos += len;
501
- const [oldOid, newOid, refName] = (line ?? "").split(" ");
502
- if (oldOid && newOid && refName && OID_RE.test(oldOid) && OID_RE.test(newOid)) {
503
- refUpdates.push({ oldOid, newOid, refName });
504
- }
505
- }
506
- return {
507
- refUpdates,
508
- packData: body.subarray(pos),
509
- capabilities: capabilities ?? []
510
- };
540
+ const refUpdates = [];
541
+ let capabilities;
542
+ let pos = 0;
543
+ while (pos + 4 <= body.length) {
544
+ const len = Number.parseInt(decodeAscii(body.subarray(pos, pos + 4)), 16);
545
+ if (len === 0) {
546
+ pos += 4;
547
+ break;
548
+ }
549
+ if (len < 4) break;
550
+ const [line, caps] = decodeUtf8(body.subarray(pos + 4, pos + len)).replace(/\n$/, "").split("\0");
551
+ if (capabilities === void 0) capabilities = (caps ?? "").split(" ").filter(Boolean);
552
+ pos += len;
553
+ const [oldOid, newOid, refName] = (line ?? "").split(" ");
554
+ if (oldOid && newOid && refName && OID_RE$1.test(oldOid) && OID_RE$1.test(newOid)) refUpdates.push({
555
+ oldOid,
556
+ newOid,
557
+ refName
558
+ });
559
+ }
560
+ return {
561
+ refUpdates,
562
+ packData: body.subarray(pos),
563
+ capabilities: capabilities ?? []
564
+ };
511
565
  }
566
+ /** Initialize the repo when its HEAD doesn't exist yet (first push). */
512
567
  async function ensureRepoInitialized(repo, defaultBranch) {
513
- const fsp = rawFs(repo);
514
- try {
515
- await fsp.stat(`${repo.gitdir}/HEAD`);
516
- } catch {
517
- await fsp.mkdir(repo.gitdir, { recursive: true }).catch(() => {
518
- });
519
- await git4.init({ ...repo, dir: repo.gitdir, defaultBranch, bare: true });
520
- }
568
+ const fsp = rawFs(repo);
569
+ try {
570
+ await fsp.stat(`${repo.gitdir}/HEAD`);
571
+ } catch (error) {
572
+ if (error?.code !== "ENOENT") throw error;
573
+ await fsp.mkdir(repo.gitdir, { recursive: true }).catch(() => {});
574
+ await git.init({
575
+ ...repo,
576
+ dir: repo.gitdir,
577
+ defaultBranch,
578
+ bare: true
579
+ });
580
+ }
521
581
  }
582
+ /** Write the incoming PACK into objects/pack/ and index it. */
522
583
  async function indexIncomingPack(repo, packData, hooks) {
523
- if (packData.length < 4) return [];
524
- const packName = await runStep(
525
- hooks,
526
- "write + indexPack incoming pack",
527
- async () => {
528
- const { base } = await storePack(repo, packData);
529
- return base;
530
- }
531
- );
532
- return [
533
- `${repo.gitdir}/objects/pack/${packName}.pack`,
534
- `${repo.gitdir}/objects/pack/${packName}.idx`
535
- ];
584
+ if (packData.length < 4) return [];
585
+ const packName = await runStep(hooks, "write + indexPack incoming pack", async () => {
586
+ const { base } = await storePack(repo, packData);
587
+ return base;
588
+ });
589
+ return [`${repo.gitdir}/objects/pack/${packName}.pack`, `${repo.gitdir}/objects/pack/${packName}.idx`];
536
590
  }
591
+ /**
592
+ * Validate a ref name and compare-and-swap it against its claimed current
593
+ * oid. Returns the failure result to report if either check fails, or
594
+ * `null` when both pass — shared by the preflight check (`validateRefUpdates`,
595
+ * run before the possibly-slow pack indexing/graph-walk work, purely to
596
+ * avoid doing that work for an update already doomed to fail) and the real
597
+ * atomicity guarantee (`applyRefUpdates`, run again right before writing,
598
+ * since a concurrent push could have moved the ref in the time that work
599
+ * took).
600
+ */
537
601
  async function checkRefCas(repo, refName, oldOid) {
538
- if (!isSafeFullRefName(refName)) {
539
- return { refName, ok: false, reason: "invalid ref name" };
540
- }
541
- const currentOid = await git4.resolveRef({ ...repo, ref: refName }).catch(() => ZERO_OID);
542
- return currentOid === oldOid ? null : {
543
- refName,
544
- ok: false,
545
- reason: "non-fast-forward, ref updated by another push"
546
- };
602
+ if (!isSafeFullRefName(refName)) return {
603
+ refName,
604
+ ok: false,
605
+ reason: "invalid ref name"
606
+ };
607
+ let currentOid;
608
+ try {
609
+ currentOid = await git.resolveRef({
610
+ ...repo,
611
+ ref: refName
612
+ });
613
+ } catch (error) {
614
+ if (!isNotFoundError(error)) throw error;
615
+ currentOid = ZERO_OID$1;
616
+ }
617
+ return currentOid === oldOid ? null : {
618
+ refName,
619
+ ok: false,
620
+ reason: "non-fast-forward, ref updated by another push"
621
+ };
547
622
  }
623
+ /** Every branch/tag tip currently in the repo, as a validation boundary — see
624
+ * its call site in {@link applyReceivePack}. */
548
625
  async function collectExistingRefTips(repo) {
549
- const [branches, tags] = await Promise.all([
550
- git4.listBranches(repo),
551
- git4.listTags(repo)
552
- ]);
553
- const refs = [
554
- ...branches.map((b) => `refs/heads/${b}`),
555
- ...tags.map((t) => `refs/tags/${t}`)
556
- ];
557
- const oids = await Promise.all(
558
- refs.map((ref) => git4.resolveRef({ ...repo, ref }).catch(() => null))
559
- );
560
- return new Set(oids.filter((oid) => oid !== null));
626
+ const [branches, tags] = await Promise.all([git.listBranches(repo), git.listTags(repo)]);
627
+ const refs = [...branches.map((b) => `refs/heads/${b}`), ...tags.map((t) => `refs/tags/${t}`)];
628
+ const oids = await Promise.all(refs.map(async (ref) => {
629
+ try {
630
+ return await git.resolveRef({
631
+ ...repo,
632
+ ref
633
+ });
634
+ } catch (error) {
635
+ if (isNotFoundError(error)) return null;
636
+ throw error;
637
+ }
638
+ }));
639
+ return new Set(oids.filter((oid) => oid !== null));
561
640
  }
641
+ /**
642
+ * The branch HEAD names, or null when HEAD is detached or unborn. A HEAD read
643
+ * that *failed* must reject the push: `validateRefUpdates` reads null as "no
644
+ * default branch to protect", so swallowing a real failure would let a push
645
+ * delete the branch HEAD points at, leaving the repository with no default
646
+ * branch (see #91).
647
+ */
562
648
  async function currentBranchRef(repo) {
563
- return git4.currentBranch({ ...repo, fullname: true }).then((ref) => ref ?? null).catch(() => null);
649
+ try {
650
+ return await git.currentBranch({
651
+ ...repo,
652
+ fullname: true
653
+ }) ?? null;
654
+ } catch (error) {
655
+ if (!isNotFoundError(error)) throw error;
656
+ return null;
657
+ }
564
658
  }
565
659
  async function validateRefUpdates(repo, refUpdates) {
566
- const headRef = await currentBranchRef(repo);
567
- const seen = /* @__PURE__ */ new Set();
568
- return Promise.all(
569
- refUpdates.map(async ({ oldOid, newOid, refName }) => {
570
- if (seen.has(refName)) {
571
- return { refName, ok: false, reason: "duplicate ref update" };
572
- }
573
- seen.add(refName);
574
- if (newOid === ZERO_OID && refName === headRef) {
575
- return {
576
- refName,
577
- ok: false,
578
- reason: "deletion of the current branch prohibited"
579
- };
580
- }
581
- return await checkRefCas(repo, refName, oldOid) ?? { refName, ok: true };
582
- })
583
- );
660
+ const headRef = await currentBranchRef(repo);
661
+ const seen = /* @__PURE__ */ new Set();
662
+ return Promise.all(refUpdates.map(async ({ oldOid, newOid, refName }) => {
663
+ if (seen.has(refName)) return {
664
+ refName,
665
+ ok: false,
666
+ reason: "duplicate ref update"
667
+ };
668
+ seen.add(refName);
669
+ if (newOid === ZERO_OID$1 && refName === headRef) return {
670
+ refName,
671
+ ok: false,
672
+ reason: "deletion of the current branch prohibited"
673
+ };
674
+ return await checkRefCas(repo, refName, oldOid) ?? {
675
+ refName,
676
+ ok: true
677
+ };
678
+ }));
584
679
  }
680
+ /**
681
+ * Apply ref updates, enforcing compare-and-swap against each command's
682
+ * claimed oldOid. Every client-supplied refName is validated before any
683
+ * filesystem call reads or writes through it.
684
+ */
585
685
  async function applyRefUpdates(repo, refUpdates, hooks) {
586
- return runStep(
587
- hooks,
588
- "apply ref updates",
589
- () => Promise.all(
590
- refUpdates.map(async ({ oldOid, newOid, refName }) => {
591
- const failure = await checkRefCas(repo, refName, oldOid);
592
- if (failure) return failure;
593
- if (newOid === ZERO_OID) {
594
- await git4.deleteRef({ ...repo, ref: refName }).catch(() => {
595
- });
596
- } else {
597
- if (refName.startsWith("refs/heads/")) {
598
- const { type } = await git4.readObject({ ...repo, oid: newOid, format: "content" }).catch(() => ({ type: "missing" }));
599
- if (type !== "commit") {
600
- return {
601
- refName,
602
- ok: false,
603
- reason: `branch must point at a commit, not a ${type}`
604
- };
605
- }
606
- }
607
- await git4.writeRef({
608
- ...repo,
609
- ref: refName,
610
- value: newOid,
611
- force: true
612
- });
613
- }
614
- return { refName, ok: true };
615
- })
616
- )
617
- );
686
+ return runStep(hooks, "apply ref updates", () => Promise.all(refUpdates.map(async ({ oldOid, newOid, refName }) => {
687
+ const failure = await checkRefCas(repo, refName, oldOid);
688
+ if (failure) return failure;
689
+ if (newOid === ZERO_OID$1) try {
690
+ await git.deleteRef({
691
+ ...repo,
692
+ ref: refName
693
+ });
694
+ } catch (error) {
695
+ if (!isNotFoundError(error)) throw error;
696
+ }
697
+ else {
698
+ if (refName.startsWith("refs/heads/")) {
699
+ let type;
700
+ try {
701
+ ({type} = await git.readObject({
702
+ ...repo,
703
+ oid: newOid,
704
+ format: "content"
705
+ }));
706
+ } catch (error) {
707
+ if (!isNotFoundError(error)) throw error;
708
+ type = "missing";
709
+ }
710
+ if (type !== "commit") return {
711
+ refName,
712
+ ok: false,
713
+ reason: `branch must point at a commit, not a ${type}`
714
+ };
715
+ }
716
+ await git.writeRef({
717
+ ...repo,
718
+ ref: refName,
719
+ value: newOid,
720
+ force: true
721
+ });
722
+ }
723
+ return {
724
+ refName,
725
+ ok: true
726
+ };
727
+ })));
618
728
  }
729
+ /**
730
+ * The report-status response for a set of ref-update results — empty unless
731
+ * the client asked for `report-status`, and side-band-64k framed when it
732
+ * asked for that (git and isomorphic-git both do; isomorphic-git always
733
+ * demuxes the response as side-band). `unpackError` reports a pack that
734
+ * couldn't be stored or indexed.
735
+ */
619
736
  function receivePackResponse(results, options) {
620
- const capabilities = options?.capabilities ?? ["report-status"];
621
- const report = capabilities.includes("report-status") ? concat(
622
- pktLine(
623
- options?.unpackError ? `unpack ${options.unpackError}
624
- ` : "unpack ok\n"
625
- ),
626
- ...results.map(
627
- ({ refName, ok, reason }) => pktLine(ok ? `ok ${refName}
628
- ` : `ng ${refName} ${reason}
629
- `)
630
- ),
631
- FLUSH
632
- ) : new Uint8Array();
633
- const responseBody = capabilities.includes("side-band-64k") && report.length > 0 ? sideBandPackfile(report) : report;
634
- return {
635
- status: 200,
636
- headers: {
637
- "Content-Type": "application/x-git-receive-pack-result",
638
- "Cache-Control": "no-cache"
639
- },
640
- body: responseBody
641
- };
737
+ const capabilities = options?.capabilities ?? ["report-status"];
738
+ const report = capabilities.includes("report-status") ? concat(pktLine(options?.unpackError ? `unpack ${options.unpackError}\n` : "unpack ok\n"), ...results.map(({ refName, ok, reason }) => pktLine(ok ? `ok ${refName}\n` : `ng ${refName} ${reason}\n`)), FLUSH) : /* @__PURE__ */ new Uint8Array();
739
+ return {
740
+ status: 200,
741
+ headers: {
742
+ "Content-Type": "application/x-git-receive-pack-result",
743
+ "Cache-Control": "no-cache"
744
+ },
745
+ body: capabilities.includes("side-band-64k") && report.length > 0 ? sideBandPackfile(report) : report
746
+ };
642
747
  }
748
+ /**
749
+ * The storage half of a push against `repo`: initialize if needed, index the
750
+ * incoming pack, CAS-apply ref updates, then repack when enough packs have
751
+ * accumulated. Serialize concurrent pushes to the same repo externally (a
752
+ * per-repo lock); build the HTTP response afterwards with
753
+ * {@link receivePackResponse}.
754
+ *
755
+ * A push that changes nothing must not leak its pack: ref updates only ever
756
+ * fail here (a stale compare-and-swap, a branch pointed at a non-commit,
757
+ * HEAD's branch deleted), so when none of them succeeded the incoming pack is
758
+ * unlinked again — no ref names it, and the threshold repack only runs for
759
+ * accepted pushes, so nothing would ever reclaim it.
760
+ */
643
761
  async function applyReceivePack(repo, parsed, options, hooks) {
644
- await ensureRepoInitialized(repo, options.defaultBranch);
645
- const preflight = await validateRefUpdates(repo, parsed.refUpdates);
646
- const acceptedUpdates = parsed.refUpdates.filter(
647
- (_, index) => preflight[index]?.ok
648
- );
649
- const fsp = rawFs(repo);
650
- const packDir = `${repo.gitdir}/objects/pack`;
651
- const existingPacks = new Set(
652
- await fsp.readdir(packDir).catch(() => [])
653
- );
654
- let incomingPackPaths;
655
- try {
656
- incomingPackPaths = await indexIncomingPack(
657
- repo,
658
- acceptedUpdates.some((update) => update.newOid !== ZERO_OID) ? parsed.packData : new Uint8Array(),
659
- hooks
660
- );
661
- } catch (err) {
662
- hooks?.onWarn?.("receive-pack: incoming pack rejected", err);
663
- return {
664
- results: preflight.map(
665
- (result2) => result2.ok ? { ...result2, ok: false, reason: "unpacker error" } : result2
666
- ),
667
- stalePackPaths: [],
668
- unpackError: "index-pack failed"
669
- };
670
- }
671
- const removableIncoming = incomingPackPaths.filter((path) => !existingPacks.has(path.slice(packDir.length + 1))).sort((a, b) => Number(b.endsWith(".idx")) - Number(a.endsWith(".idx")));
672
- const newTips = acceptedUpdates.filter((update) => update.newOid !== ZERO_OID).map((update) => update.newOid);
673
- if (newTips.length > 0) {
674
- const haveOids = await collectExistingRefTips(repo);
675
- for (const update of acceptedUpdates) {
676
- if (update.oldOid !== ZERO_OID) haveOids.add(update.oldOid);
677
- }
678
- const { complete } = await collectReachableOids(
679
- repo,
680
- newTips,
681
- hooks,
682
- haveOids
683
- );
684
- if (!complete) {
685
- for (const filepath of removableIncoming) {
686
- await fsp.unlink(filepath).catch(() => {
687
- });
688
- }
689
- return {
690
- results: preflight.map(
691
- (result2) => result2.ok ? { ...result2, ok: false, reason: "incomplete object graph" } : result2
692
- ),
693
- stalePackPaths: []
694
- };
695
- }
696
- }
697
- const applied = await applyRefUpdates(repo, acceptedUpdates, hooks);
698
- const results = preflight.map(
699
- (result2, index) => result2.ok ? applied.find(
700
- (entry) => entry.refName === parsed.refUpdates[index]?.refName
701
- ) ?? result2 : result2
702
- );
703
- if (!results.some((result2) => result2.ok)) {
704
- for (const filepath of removableIncoming) {
705
- await fsp.unlink(filepath).catch(() => {
706
- });
707
- }
708
- return { results, stalePackPaths: [] };
709
- }
710
- const repackOptions = options.repack;
711
- const stalePackPaths = repackOptions === false ? [] : await runStep(
712
- hooks,
713
- "repack",
714
- () => repackRepository(repo, repackOptions, hooks)
715
- );
716
- return { results, stalePackPaths };
762
+ await ensureRepoInitialized(repo, options.defaultBranch);
763
+ const preflight = await validateRefUpdates(repo, parsed.refUpdates);
764
+ const acceptedUpdates = parsed.refUpdates.filter((_, index) => preflight[index]?.ok);
765
+ const fsp = rawFs(repo);
766
+ const packDir = `${repo.gitdir}/objects/pack`;
767
+ const existingPacks = new Set(await fsp.readdir(packDir).catch(() => []));
768
+ let incomingPackPaths;
769
+ try {
770
+ incomingPackPaths = await indexIncomingPack(repo, acceptedUpdates.some((update) => update.newOid !== ZERO_OID$1) ? parsed.packData : /* @__PURE__ */ new Uint8Array(), hooks);
771
+ } catch (err) {
772
+ hooks?.onWarn?.("receive-pack: incoming pack rejected", err);
773
+ return {
774
+ results: preflight.map((result) => result.ok ? {
775
+ ...result,
776
+ ok: false,
777
+ reason: "unpacker error"
778
+ } : result),
779
+ stalePackPaths: [],
780
+ unpackError: "index-pack failed"
781
+ };
782
+ }
783
+ const removableIncoming = incomingPackPaths.filter((path) => !existingPacks.has(path.slice(packDir.length + 1))).sort((a, b) => Number(b.endsWith(".idx")) - Number(a.endsWith(".idx")));
784
+ const newTips = acceptedUpdates.filter((update) => update.newOid !== ZERO_OID$1).map((update) => update.newOid);
785
+ if (newTips.length > 0) {
786
+ const haveOids = await collectExistingRefTips(repo);
787
+ for (const update of acceptedUpdates) if (update.oldOid !== ZERO_OID$1) haveOids.add(update.oldOid);
788
+ const { complete } = await collectReachableOids(repo, newTips, hooks, haveOids);
789
+ if (!complete) {
790
+ for (const filepath of removableIncoming) await fsp.unlink(filepath).catch(() => {});
791
+ return {
792
+ results: preflight.map((result) => result.ok ? {
793
+ ...result,
794
+ ok: false,
795
+ reason: "incomplete object graph"
796
+ } : result),
797
+ stalePackPaths: []
798
+ };
799
+ }
800
+ }
801
+ const applied = await applyRefUpdates(repo, acceptedUpdates, hooks);
802
+ const results = preflight.map((result, index) => result.ok ? applied.find((entry) => entry.refName === parsed.refUpdates[index]?.refName) ?? result : result);
803
+ if (!results.some((result) => result.ok)) {
804
+ for (const filepath of removableIncoming) await fsp.unlink(filepath).catch(() => {});
805
+ return {
806
+ results,
807
+ stalePackPaths: []
808
+ };
809
+ }
810
+ const repackOptions = options.repack;
811
+ let stalePackPaths = [];
812
+ if (repackOptions !== false) try {
813
+ stalePackPaths = await runStep(hooks, "repack", () => repackRepository(repo, repackOptions, hooks));
814
+ } catch (error) {
815
+ hooks?.onWarn?.("repack after push failed (non-fatal)", error);
816
+ }
817
+ return {
818
+ results,
819
+ stalePackPaths
820
+ };
717
821
  }
718
-
719
- // src/http/upload-pack.ts
720
- import git5 from "isomorphic-git";
721
- var ZERO_OID2 = "0".repeat(40);
722
- var OID_RE2 = /^[0-9a-f]{40}$/;
723
- var LOOSE_FANOUT_RE = /^[0-9a-f]{2}$/;
822
+ //#endregion
823
+ //#region src/http/upload-pack.ts
824
+ const ZERO_OID = "0".repeat(40);
825
+ /**
826
+ * `POST …/git-upload-pack` — serve a clone/fetch. Authorization is the
827
+ * caller's job. Negotiation is client-driven (no multi_ack advertised):
828
+ * "have" batches without "done" get a bare NAK; the final batch gets the
829
+ * packfile, side-band-64k framed.
830
+ *
831
+ * Fast path: a fresh clone (no haves) of a repo consolidated down to a
832
+ * single pack serves that pack's bytes directly — skipping the O(objects)
833
+ * traversal + repack entirely.
834
+ */
835
+ const OID_RE = /^[0-9a-f]{40}$/;
836
+ const LOOSE_FANOUT_RE = /^[0-9a-f]{2}$/;
724
837
  function result(body) {
725
- return {
726
- status: 200,
727
- headers: {
728
- "Content-Type": "application/x-git-upload-pack-result",
729
- "Cache-Control": "no-cache"
730
- },
731
- body
732
- };
838
+ return {
839
+ status: 200,
840
+ headers: {
841
+ "Content-Type": "application/x-git-upload-pack-result",
842
+ "Cache-Control": "no-cache"
843
+ },
844
+ body
845
+ };
733
846
  }
847
+ /** A protocol-level error the client prints ("ERR …"), not an HTTP failure. */
734
848
  function errorResult(message) {
735
- return result(pktLine(`ERR ${message}
736
- `));
849
+ return result(pktLine(`ERR ${message}\n`));
737
850
  }
851
+ /** Parsed upload-pack request: oids are always 40 lowercase hex digits. */
738
852
  function parseUploadPackRequest(body) {
739
- const wants = [];
740
- const haves = [];
741
- let done = false;
742
- let invalid = false;
743
- let capabilities;
744
- for (const line of parsePktLines(body)) {
745
- if (!line) continue;
746
- const text = line.replace(/\n$/, "");
747
- if (text.startsWith("want ")) {
748
- const [oid, ...caps] = text.slice(5).split(" ");
749
- if (capabilities === void 0) capabilities = caps.filter(Boolean);
750
- if (oid !== void 0 && OID_RE2.test(oid)) wants.push(oid);
751
- else invalid = true;
752
- } else if (text.startsWith("have ")) {
753
- const oid = text.slice(5).trim();
754
- if (OID_RE2.test(oid)) {
755
- if (oid !== ZERO_OID2) haves.push(oid);
756
- } else {
757
- invalid = true;
758
- }
759
- } else if (text === "done") {
760
- done = true;
761
- }
762
- }
763
- return {
764
- wants: [...new Set(wants)],
765
- haves: [...new Set(haves)],
766
- done,
767
- capabilities: capabilities ?? [],
768
- invalid
769
- };
853
+ const wants = [];
854
+ const haves = [];
855
+ let done = false;
856
+ let invalid = false;
857
+ let capabilities;
858
+ for (const line of parsePktLines(body)) {
859
+ if (!line) continue;
860
+ const text = line.replace(/\n$/, "");
861
+ if (text.startsWith("want ")) {
862
+ const [oid, ...caps] = text.slice(5).split(" ");
863
+ if (capabilities === void 0) capabilities = caps.filter(Boolean);
864
+ if (oid !== void 0 && OID_RE.test(oid)) wants.push(oid);
865
+ else invalid = true;
866
+ } else if (text.startsWith("have ")) {
867
+ const oid = text.slice(5).trim();
868
+ if (OID_RE.test(oid)) {
869
+ if (oid !== ZERO_OID) haves.push(oid);
870
+ } else invalid = true;
871
+ } else if (text === "done") done = true;
872
+ }
873
+ return {
874
+ wants: [...new Set(wants)],
875
+ haves: [...new Set(haves)],
876
+ done,
877
+ capabilities: capabilities ?? [],
878
+ invalid
879
+ };
770
880
  }
881
+ /**
882
+ * The single pack a fresh clone can be served verbatim, or null. Only safe
883
+ * when nothing lives outside it (no loose objects, exactly one pack) and it
884
+ * holds every wanted object — a repo with commits written as loose objects
885
+ * after its last repack would otherwise clone incomplete.
886
+ */
771
887
  async function servableSinglePack(repo, wants, hooks) {
772
- const fsp = rawFs(repo);
773
- const objectsDir = `${repo.gitdir}/objects`;
774
- const [objectEntries, packEntries] = await runStep(
775
- hooks,
776
- "readdir objects + objects/pack",
777
- () => Promise.all([
778
- fsp.readdir(objectsDir).catch(() => []),
779
- fsp.readdir(`${objectsDir}/pack`).catch(() => [])
780
- ])
781
- );
782
- if (objectEntries.some((name) => LOOSE_FANOUT_RE.test(name))) return null;
783
- const packs = parsePackDirectory(packEntries).filter(
784
- (file) => file.kind === "pack"
785
- );
786
- const pack = packs[0];
787
- if (packs.length !== 1 || pack === void 0) return null;
788
- const [packData, idxData] = await runStep(
789
- hooks,
790
- "read consolidated pack (fast path)",
791
- () => Promise.all([
792
- fsp.readFile(`${objectsDir}/pack/${pack.name}`),
793
- fsp.readFile(`${objectsDir}/pack/${packFileName(pack.base, "idx")}`)
794
- ]).catch(() => [null, null])
795
- );
796
- if (!(packData instanceof Uint8Array) || !(idxData instanceof Uint8Array)) {
797
- return null;
798
- }
799
- const indexed = readPackIndex(idxData);
800
- if (!indexed) return null;
801
- const oids = new Set(indexed.map((entry) => entry.oid));
802
- return wants.every((oid) => oids.has(oid)) ? packData : null;
888
+ const fsp = rawFs(repo);
889
+ const objectsDir = `${repo.gitdir}/objects`;
890
+ const [objectEntries, packEntries] = await runStep(hooks, "readdir objects + objects/pack", () => Promise.all([fsp.readdir(objectsDir).catch(() => []), fsp.readdir(`${objectsDir}/pack`).catch(() => [])]));
891
+ if (objectEntries.some((name) => LOOSE_FANOUT_RE.test(name))) return null;
892
+ const packs = parsePackDirectory(packEntries).filter((file) => file.kind === "pack");
893
+ const pack = packs[0];
894
+ if (packs.length !== 1 || pack === void 0) return null;
895
+ const [packData, idxData] = await runStep(hooks, "read consolidated pack (fast path)", () => Promise.all([fsp.readFile(`${objectsDir}/pack/${pack.name}`), fsp.readFile(`${objectsDir}/pack/${packFileName(pack.base, "idx")}`)]).catch(() => [null, null]));
896
+ if (!(packData instanceof Uint8Array) || !(idxData instanceof Uint8Array)) return null;
897
+ const indexed = readPackIndex(idxData);
898
+ if (!indexed) return null;
899
+ const oids = new Set(indexed.map((entry) => entry.oid));
900
+ return wants.every((oid) => oids.has(oid)) ? packData : null;
803
901
  }
902
+ /**
903
+ * `POST …/git-upload-pack` — serve a clone/fetch. Authorization is the
904
+ * caller's job. Negotiation is client-driven (no multi_ack advertised):
905
+ * "have" batches without "done" get a bare NAK; the final batch gets the
906
+ * packfile, side-band-64k framed when the client asked for it.
907
+ *
908
+ * Only objects reachable from the repository's refs are served: a want that
909
+ * isn't a ref tip must be an ancestor of one ("not our ref" otherwise), so a
910
+ * force-pushed-away commit still sitting in a pack can't be fetched by id.
911
+ *
912
+ * A fetch with haves reads only the client's commit ancestry plus the trees
913
+ * of the boundary commits (the haves the new history builds on) — not every
914
+ * tree the client already has.
915
+ *
916
+ * Fast path: a fresh clone (no haves) of a repo consolidated down to a
917
+ * single pack, with no loose objects, serves that pack's bytes directly —
918
+ * skipping the O(objects) traversal + repack entirely.
919
+ */
804
920
  async function handleUploadPack(repo, body, options, hooks) {
805
- const request = parseUploadPackRequest(body);
806
- if (request.invalid) {
807
- return errorResult("upload-pack: protocol error, malformed object id");
808
- }
809
- const { wants, haves, done, capabilities } = request;
810
- const frame = (pack) => capabilities.includes("side-band-64k") || capabilities.includes("side-band") ? sideBandPackfile(pack) : concat(pack);
811
- if (wants.length === 0 || haves.length > 0 && !done) {
812
- return result(concat(pktLine("NAK\n")));
813
- }
814
- const { refs } = await runStep(
815
- hooks,
816
- "listAllRefs",
817
- () => listAllRefs(repo, "HEAD")
818
- );
819
- const tips = new Set(refs.map((ref) => ref.oid));
820
- const untipped = wants.filter((oid) => !tips.has(oid));
821
- if (untipped.length > 0) {
822
- const { commits } = await runStep(
823
- hooks,
824
- "verify wants reachable",
825
- () => collectCommitAncestry(repo, [...tips])
826
- );
827
- const missing = untipped.find((oid) => !commits.has(oid));
828
- if (missing !== void 0) {
829
- return errorResult(`upload-pack: not our ref ${missing}`);
830
- }
831
- }
832
- if (haves.length === 0) {
833
- const pack = await servableSinglePack(repo, wants, hooks);
834
- if (pack) return result(concat(pktLine("NAK\n"), frame(pack)));
835
- }
836
- if (options?.beforeWalk) {
837
- await runStep(hooks, "beforeWalk", options.beforeWalk);
838
- }
839
- let haveSet;
840
- let commonHave;
841
- if (haves.length > 0) {
842
- haveSet = await runStep(hooks, "have boundary", async () => {
843
- const { commits: clientCommits } = await collectCommitAncestry(
844
- repo,
845
- haves
846
- );
847
- commonHave = haves.find((oid) => clientCommits.has(oid));
848
- const { boundary } = await collectCommitAncestry(
849
- repo,
850
- wants,
851
- clientCommits
852
- );
853
- const boundaryTrees = await Promise.all(
854
- [...boundary].map(async (oid) => {
855
- const { commit } = await git5.readCommit({ ...repo, oid });
856
- return commit.tree;
857
- })
858
- );
859
- const { oids: boundaryObjects } = await collectReachableOids(
860
- repo,
861
- boundaryTrees,
862
- hooks,
863
- void 0,
864
- { verifyBlobs: false }
865
- );
866
- return /* @__PURE__ */ new Set([...clientCommits, ...boundaryObjects]);
867
- });
868
- }
869
- const { oids: wantOids, complete } = await runStep(
870
- hooks,
871
- "collectReachableOids(wants)",
872
- () => collectReachableOids(repo, wants, hooks, haveSet, {
873
- verifyBlobs: false
874
- })
875
- );
876
- if (!complete) {
877
- return errorResult("upload-pack: repository is missing objects");
878
- }
879
- const oids = haveSet ? wantOids.filter((oid) => !haveSet.has(oid)) : wantOids;
880
- const reused = await runStep(
881
- hooks,
882
- `reuse pack entries (${oids.length} oids)`,
883
- () => buildPackFromExistingPacks(repo, oids)
884
- );
885
- const packBytes = reused ?? await runStep(hooks, `packObjects (${oids.length} oids)`, async () => {
886
- const { packfile } = await git5.packObjects({ ...repo, oids });
887
- return packfile instanceof Uint8Array ? packfile : new Uint8Array(packfile ?? []);
888
- });
889
- const ack = commonHave ? `ACK ${commonHave}
890
- ` : "NAK\n";
891
- return result(concat(pktLine(ack), frame(packBytes)));
921
+ const request = parseUploadPackRequest(body);
922
+ if (request.invalid) return errorResult("upload-pack: protocol error, malformed object id");
923
+ const { wants, haves, done, capabilities } = request;
924
+ const frame = (pack) => capabilities.includes("side-band-64k") || capabilities.includes("side-band") ? sideBandPackfile(pack) : concat(pack);
925
+ if (wants.length === 0 || haves.length > 0 && !done) return result(concat(pktLine("NAK\n")));
926
+ const { refs } = await runStep(hooks, "listAllRefs", () => listAllRefs(repo, "HEAD"));
927
+ const tips = new Set(refs.map((ref) => ref.oid));
928
+ const untipped = wants.filter((oid) => !tips.has(oid));
929
+ if (untipped.length > 0) {
930
+ const { commits } = await runStep(hooks, "verify wants reachable", () => collectCommitAncestry(repo, [...tips]));
931
+ const missing = untipped.find((oid) => !commits.has(oid));
932
+ if (missing !== void 0) return errorResult(`upload-pack: not our ref ${missing}`);
933
+ }
934
+ if (haves.length === 0) {
935
+ const pack = await servableSinglePack(repo, wants, hooks);
936
+ if (pack) return result(concat(pktLine("NAK\n"), frame(pack)));
937
+ }
938
+ if (options?.beforeWalk) await runStep(hooks, "beforeWalk", options.beforeWalk);
939
+ let haveSet;
940
+ let commonHave;
941
+ if (haves.length > 0) haveSet = await runStep(hooks, "have boundary", async () => {
942
+ const { commits: clientCommits } = await collectCommitAncestry(repo, haves);
943
+ commonHave = haves.find((oid) => clientCommits.has(oid));
944
+ const { boundary } = await collectCommitAncestry(repo, wants, clientCommits);
945
+ const { oids: boundaryObjects } = await collectReachableOids(repo, await Promise.all([...boundary].map(async (oid) => {
946
+ const { commit } = await git.readCommit({
947
+ ...repo,
948
+ oid
949
+ });
950
+ return commit.tree;
951
+ })), hooks, void 0, { verifyBlobs: false });
952
+ return /* @__PURE__ */ new Set([...clientCommits, ...boundaryObjects]);
953
+ });
954
+ const { oids: wantOids, complete } = await runStep(hooks, "collectReachableOids(wants)", () => collectReachableOids(repo, wants, hooks, haveSet, { verifyBlobs: false }));
955
+ if (!complete) return errorResult("upload-pack: repository is missing objects");
956
+ const oids = haveSet ? wantOids.filter((oid) => !haveSet.has(oid)) : wantOids;
957
+ const packBytes = await runStep(hooks, `reuse pack entries (${oids.length} oids)`, () => buildPackFromExistingPacks(repo, oids)) ?? await runStep(hooks, `packObjects (${oids.length} oids)`, async () => {
958
+ const { packfile } = await git.packObjects({
959
+ ...repo,
960
+ oids
961
+ });
962
+ return packfile instanceof Uint8Array ? packfile : new Uint8Array(packfile ?? []);
963
+ });
964
+ const ack = commonHave ? `ACK ${commonHave}\n` : "NAK\n";
965
+ return result(concat(pktLine(ack), frame(packBytes)));
892
966
  }
893
- export {
894
- FLUSH,
895
- REPACK_PACK_COUNT_THRESHOLD,
896
- applyReceivePack,
897
- applyRefUpdates,
898
- collectReachableOids,
899
- ensureRepoInitialized,
900
- handleInfoRefs,
901
- handleUploadPack,
902
- indexIncomingPack,
903
- listAllRefs,
904
- parsePktLines,
905
- parseReceivePackBody,
906
- parseUploadPackRequest,
907
- pktLine,
908
- pktLineBuffer,
909
- rawFs,
910
- receivePackResponse,
911
- repackRepository,
912
- sideBandPackfile
913
- };
967
+ //#endregion
968
+ export { FLUSH, REPACK_PACK_COUNT_THRESHOLD, applyReceivePack, applyRefUpdates, collectReachableOids, ensureRepoInitialized, handleInfoRefs, handleUploadPack, indexIncomingPack, listAllRefs, parsePktLines, parseReceivePackBody, parseUploadPackRequest, pktLine, pktLineBuffer, rawFs, receivePackResponse, repackRepository, sideBandPackfile };
969
+
914
970
  //# sourceMappingURL=http.js.map