git-fs-s3 0.15.2 → 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/README.md +5 -3
- package/dist/fetch-s3.js +137 -160
- package/dist/fetch-s3.js.map +1 -1
- package/dist/http.js +905 -849
- package/dist/http.js.map +1 -1
- package/dist/index.js +578 -658
- package/dist/index.js.map +1 -1
- package/dist/memory-M2cCuVF-.js +61 -0
- package/dist/memory-M2cCuVF-.js.map +1 -0
- package/dist/node.js +75 -72
- package/dist/node.js.map +1 -1
- package/dist/object-size-BJAsnZzw.js +191 -0
- package/dist/object-size-BJAsnZzw.js.map +1 -0
- package/dist/ops.js +662 -692
- package/dist/ops.js.map +1 -1
- package/dist/refs-Cb_NfKWI.js +550 -0
- package/dist/refs-Cb_NfKWI.js.map +1 -0
- package/dist/s3.js +111 -116
- package/dist/s3.js.map +1 -1
- package/dist/semaphore-BviNoQuJ.js +646 -0
- package/dist/semaphore-BviNoQuJ.js.map +1 -0
- package/dist/types/git-errors.d.ts +6 -0
- package/dist/types/http/repack.d.ts +7 -0
- package/dist/types/index.d.ts +1 -1
- package/package.json +8 -13
- package/dist/chunk-HLYCSNJ2.js +0 -53
- package/dist/chunk-HLYCSNJ2.js.map +0 -1
- package/dist/chunk-IAKZRWBZ.js +0 -598
- package/dist/chunk-IAKZRWBZ.js.map +0 -1
- package/dist/chunk-OKBQISUC.js +0 -442
- package/dist/chunk-OKBQISUC.js.map +0 -1
- package/dist/chunk-T5M5EYCZ.js +0 -143
- package/dist/chunk-T5M5EYCZ.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,914 +1,970 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|