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/dist/ops.js CHANGED
@@ -1,740 +1,710 @@
1
- import {
2
- getObjectSize,
3
- parseLooseObjectPath
4
- } from "./chunk-T5M5EYCZ.js";
5
- import {
6
- HISTORY_WALK_DEPTH,
7
- assertBranchExists,
8
- assertSafeBranchName,
9
- createBranchFrom,
10
- createSemaphore,
11
- deleteBranchByName,
12
- deleteFromTree,
13
- encodePack,
14
- findTreeEntry,
15
- getBlob,
16
- getCommit,
17
- getCommitHistory,
18
- getCommitLog,
19
- getFileContent,
20
- getTreeFromRef,
21
- listBranches,
22
- listTags,
23
- listTreeEntries,
24
- peelToCommit,
25
- resolveCommit,
26
- resolveLooseRefFast,
27
- resultKeyPrefixes,
28
- runStep,
29
- storePack,
30
- summarizeCommit,
31
- upsertTree
32
- } from "./chunk-IAKZRWBZ.js";
33
- import {
34
- GitInvalidRequestError,
35
- GitPathNotFoundError,
36
- inflate,
37
- isSafeTreePath,
38
- qualifyBranchRef,
39
- readBlobContent
40
- } from "./chunk-OKBQISUC.js";
41
-
42
- // src/ops/commit.ts
1
+ import { D as GitPathNotFoundError, H as inflate, T as GitInvalidRequestError, W as readBlobContent, o as isSafeTreePath, s as qualifyBranchRef } from "./refs-Cb_NfKWI.js";
2
+ import { i as parseLooseObjectPath, t as getObjectSize } from "./object-size-BJAsnZzw.js";
3
+ import { C as summarizeCommit, D as findTreeEntry, E as deleteFromTree, O as listTreeEntries, S as resolveCommit, T as runStep, _ as getCommitHistory, a as storePack, b as getTreeFromRef, c as createBranchFrom, f as listTags, g as getCommit, h as getBlob, k as upsertTree, l as deleteBranchByName, m as HISTORY_WALK_DEPTH, n as encodePack, o as assertBranchExists, p as resolveLooseRefFast, s as assertSafeBranchName, t as createSemaphore, u as listBranches, v as getCommitLog, w as resultKeyPrefixes, x as peelToCommit, y as getFileContent } from "./semaphore-BviNoQuJ.js";
43
4
  import git from "isomorphic-git";
44
-
45
- // src/ops/packed-writes.ts
46
- var OBJECT_TYPES = /* @__PURE__ */ new Set(["commit", "tree", "blob", "tag"]);
5
+ import { createTwoFilesPatch } from "diff";
6
+ //#region src/ops/packed-writes.ts
7
+ /**
8
+ * Packed writes: run git plumbing (writeBlob/writeTree/writeCommit, and
9
+ * anything built on them) against a repository, but store the objects it
10
+ * creates as one pack instead of one loose object each.
11
+ *
12
+ * Why it matters on object storage: one PUT per object becomes two per
13
+ * operation (pack + index), and a repository that is never given loose
14
+ * objects stays "fully packed" forever — which is what makes the loose-object
15
+ * hint (`GitFs.detectLooseObjects`) safe across processes. A process that
16
+ * once saw "no loose objects" in a gitdir can't be told when another process
17
+ * writes one; if nothing ever writes loose objects, that answer stays true.
18
+ *
19
+ * Ordering is preserved: the pending objects are flushed (pack, then its
20
+ * index) before any other file is written — so a ref written inside `fn`
21
+ * never becomes visible before the objects it names.
22
+ */
23
+ const OBJECT_TYPES = /* @__PURE__ */ new Set([
24
+ "commit",
25
+ "tree",
26
+ "blob",
27
+ "tag"
28
+ ]);
29
+ /** Split a loose object's inflated bytes ("<type> <size>\0<content>"). */
47
30
  function parseLooseObject(raw) {
48
- const nul = raw.indexOf(0);
49
- const header = new TextDecoder().decode(raw.subarray(0, nul));
50
- const [type, size] = header.split(" ");
51
- const content = raw.slice(nul + 1);
52
- if (!OBJECT_TYPES.has(type) || Number(size) !== content.length) {
53
- throw new Error(`packed writes: malformed object header "${header}"`);
54
- }
55
- return { type, content };
31
+ const nul = raw.indexOf(0);
32
+ const header = new TextDecoder().decode(raw.subarray(0, nul));
33
+ const [type, size] = header.split(" ");
34
+ const content = raw.slice(nul + 1);
35
+ if (!OBJECT_TYPES.has(type) || Number(size) !== content.length) throw new Error(`packed writes: malformed object header "${header}"`);
36
+ return {
37
+ type,
38
+ content
39
+ };
56
40
  }
57
41
  function fileStat(size) {
58
- const epoch = /* @__PURE__ */ new Date(0);
59
- return {
60
- type: "file",
61
- mode: 33188,
62
- size,
63
- ino: 0,
64
- mtimeMs: 0,
65
- ctimeMs: 0,
66
- uid: 0,
67
- gid: 0,
68
- dev: 0,
69
- mtime: epoch,
70
- ctime: epoch,
71
- isFile: () => true,
72
- isDirectory: () => false,
73
- isSymbolicLink: () => false
74
- };
42
+ const epoch = /* @__PURE__ */ new Date(0);
43
+ return {
44
+ type: "file",
45
+ mode: 33188,
46
+ size,
47
+ ino: 0,
48
+ mtimeMs: 0,
49
+ ctimeMs: 0,
50
+ uid: 0,
51
+ gid: 0,
52
+ dev: 0,
53
+ mtime: epoch,
54
+ ctime: epoch,
55
+ isFile: () => true,
56
+ isDirectory: () => false,
57
+ isSymbolicLink: () => false
58
+ };
75
59
  }
60
+ /**
61
+ * Run `fn` with a repo whose new objects are buffered and stored as a single
62
+ * pack. Objects are flushed before any non-object write inside `fn`, and once
63
+ * more when `fn` resolves; if `fn` throws, still-unflushed objects are
64
+ * discarded (they'd be unreachable anyway). Serialize concurrent writers to
65
+ * the same repository externally, as for any write.
66
+ */
76
67
  async function withPackedWrites(repo, fn) {
77
- const base = repo.fs.promises;
78
- const pending = /* @__PURE__ */ new Map();
79
- async function flush() {
80
- if (pending.size === 0) return;
81
- const objects = await Promise.all(
82
- [...pending.values()].map(
83
- async (deflated) => parseLooseObject(await inflate(deflated))
84
- )
85
- );
86
- await storePack(repo, await encodePack(objects));
87
- pending.clear();
88
- }
89
- const promises = new Proxy(base, {
90
- get(target, property, receiver) {
91
- switch (property) {
92
- case "writeFile":
93
- return async (path, data, options) => {
94
- if (parseLooseObjectPath(path) !== null && typeof data !== "string") {
95
- pending.set(path, new Uint8Array(data));
96
- return;
97
- }
98
- await flush();
99
- return target.writeFile(path, data, options);
100
- };
101
- case "readFile":
102
- return async (path, options) => {
103
- const data = pending.get(path);
104
- if (data === void 0) return target.readFile(path, options);
105
- return data;
106
- };
107
- case "stat":
108
- case "lstat":
109
- return async (path) => {
110
- const data = pending.get(path);
111
- if (data !== void 0) return fileStat(data.length);
112
- return target[property](path);
113
- };
114
- default: {
115
- const value = Reflect.get(target, property, receiver);
116
- return typeof value === "function" ? value.bind(target) : value;
117
- }
118
- }
119
- }
120
- });
121
- const packedRepo = { ...repo, fs: { promises } };
122
- const result = await fn(packedRepo);
123
- await flush();
124
- return result;
68
+ const base = repo.fs.promises;
69
+ const pending = /* @__PURE__ */ new Map();
70
+ async function flush() {
71
+ if (pending.size === 0) return;
72
+ const objects = await Promise.all([...pending.values()].map(async (deflated) => parseLooseObject(await inflate(deflated))));
73
+ await storePack(repo, await encodePack(objects));
74
+ pending.clear();
75
+ }
76
+ const promises = new Proxy(base, { get(target, property, receiver) {
77
+ switch (property) {
78
+ case "writeFile": return async (path, data, options) => {
79
+ if (parseLooseObjectPath(path) !== null && typeof data !== "string") {
80
+ pending.set(path, new Uint8Array(data));
81
+ return;
82
+ }
83
+ await flush();
84
+ return target.writeFile(path, data, options);
85
+ };
86
+ case "readFile": return async (path, options) => {
87
+ const data = pending.get(path);
88
+ if (data === void 0) return target.readFile(path, options);
89
+ return data;
90
+ };
91
+ case "stat":
92
+ case "lstat": return async (path) => {
93
+ const data = pending.get(path);
94
+ if (data !== void 0) return fileStat(data.length);
95
+ return target[property](path);
96
+ };
97
+ default: {
98
+ const value = Reflect.get(target, property, receiver);
99
+ return typeof value === "function" ? value.bind(target) : value;
100
+ }
101
+ }
102
+ } });
103
+ const result = await fn({
104
+ ...repo,
105
+ fs: { promises }
106
+ });
107
+ await flush();
108
+ return result;
125
109
  }
126
-
127
- // src/ops/commit.ts
110
+ //#endregion
111
+ //#region src/ops/commit.ts
112
+ /** An author stamped with the current time. */
128
113
  function authorNow(name, email) {
129
- return {
130
- name,
131
- email,
132
- timestamp: Math.floor(Date.now() / 1e3),
133
- timezoneOffset: 0
134
- };
114
+ return {
115
+ name,
116
+ email,
117
+ timestamp: Math.floor(Date.now() / 1e3),
118
+ timezoneOffset: 0
119
+ };
135
120
  }
121
+ /**
122
+ * Write a commit directly to a bare repository — no worktree, no checkout.
123
+ * `buildTree` receives the parent commit's tree oid (undefined on an empty
124
+ * repo / unborn branch) and returns the new root tree oid; this function
125
+ * writes the commit object and force-updates `refs/heads/<branch>`.
126
+ *
127
+ * Every object the commit creates (blobs, trees, the commit) is stored as one
128
+ * pack (see {@link withPackedWrites}), written before the ref.
129
+ *
130
+ * Serialize concurrent writers externally (a per-repo lock): the
131
+ * resolve-ref → write-ref sequence is not atomic on object storage.
132
+ */
136
133
  async function writeCommitToBare(repo, options) {
137
- assertSafeBranchName(options.branch);
138
- return withPackedWrites(repo, (packed) => writeCommit(packed, options));
134
+ assertSafeBranchName(options.branch);
135
+ return withPackedWrites(repo, (packed) => writeCommit(packed, options));
139
136
  }
140
137
  async function writeCommit(repo, options) {
141
- let parentOid;
142
- let parentTreeOid;
143
- try {
144
- parentOid = await git.resolveRef({
145
- ...repo,
146
- ref: `refs/heads/${options.branch}`
147
- });
148
- const { commit } = await git.readCommit({ ...repo, oid: parentOid });
149
- parentTreeOid = commit.tree;
150
- } catch (err) {
151
- if (err?.code !== "NotFoundError") {
152
- throw err;
153
- }
154
- }
155
- const treeOid = await options.buildTree(parentTreeOid, repo);
156
- const commitOid = await git.writeCommit({
157
- ...repo,
158
- commit: {
159
- message: options.message,
160
- tree: treeOid,
161
- parent: parentOid ? [parentOid] : [],
162
- author: options.author,
163
- committer: options.author
164
- }
165
- });
166
- await git.writeRef({
167
- ...repo,
168
- ref: `refs/heads/${options.branch}`,
169
- value: commitOid,
170
- force: true
171
- });
172
- return commitOid;
138
+ let parentOid;
139
+ let parentTreeOid;
140
+ try {
141
+ parentOid = await git.resolveRef({
142
+ ...repo,
143
+ ref: `refs/heads/${options.branch}`
144
+ });
145
+ const { commit } = await git.readCommit({
146
+ ...repo,
147
+ oid: parentOid
148
+ });
149
+ parentTreeOid = commit.tree;
150
+ } catch (err) {
151
+ if (err?.code !== "NotFoundError") throw err;
152
+ }
153
+ const treeOid = await options.buildTree(parentTreeOid, repo);
154
+ const commitOid = await git.writeCommit({
155
+ ...repo,
156
+ commit: {
157
+ message: options.message,
158
+ tree: treeOid,
159
+ parent: parentOid ? [parentOid] : [],
160
+ author: options.author,
161
+ committer: options.author
162
+ }
163
+ });
164
+ await git.writeRef({
165
+ ...repo,
166
+ ref: `refs/heads/${options.branch}`,
167
+ value: commitOid,
168
+ force: true
169
+ });
170
+ return commitOid;
173
171
  }
172
+ /**
173
+ * Commit a set of files onto a branch, straight to the bare repo. Each blob
174
+ * is written to its own content-addressed key — no shared state between
175
+ * files, so they're written in parallel.
176
+ */
174
177
  async function commitFilesToBare(repo, options) {
175
- const paths = /* @__PURE__ */ new Set();
176
- for (const file of options.files) {
177
- if (!isSafeTreePath(file.path)) {
178
- throw new GitInvalidRequestError(`Invalid file path: ${file.path}`);
179
- }
180
- if (paths.has(file.path)) {
181
- throw new GitInvalidRequestError(`Duplicate file path: ${file.path}`);
182
- }
183
- paths.add(file.path);
184
- }
185
- if (paths.size === 0) {
186
- throw new GitInvalidRequestError("A commit needs at least one file");
187
- }
188
- return writeCommitToBare(repo, {
189
- branch: options.branch,
190
- message: options.message,
191
- author: options.author,
192
- buildTree: async (parentTreeOid, writeRepo) => {
193
- const blobs = /* @__PURE__ */ new Map();
194
- await Promise.all(
195
- options.files.map(async (file) => {
196
- const content = typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content;
197
- const oid = await git.writeBlob({ ...writeRepo, blob: content });
198
- blobs.set(file.path, oid);
199
- })
200
- );
201
- return upsertTree(writeRepo, parentTreeOid, blobs);
202
- }
203
- });
178
+ const paths = /* @__PURE__ */ new Set();
179
+ for (const file of options.files) {
180
+ if (!isSafeTreePath(file.path)) throw new GitInvalidRequestError(`Invalid file path: ${file.path}`);
181
+ if (paths.has(file.path)) throw new GitInvalidRequestError(`Duplicate file path: ${file.path}`);
182
+ paths.add(file.path);
183
+ }
184
+ if (paths.size === 0) throw new GitInvalidRequestError("A commit needs at least one file");
185
+ return writeCommitToBare(repo, {
186
+ branch: options.branch,
187
+ message: options.message,
188
+ author: options.author,
189
+ buildTree: async (parentTreeOid, writeRepo) => {
190
+ const blobs = /* @__PURE__ */ new Map();
191
+ await Promise.all(options.files.map(async (file) => {
192
+ const content = typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content;
193
+ const oid = await git.writeBlob({
194
+ ...writeRepo,
195
+ blob: content
196
+ });
197
+ blobs.set(file.path, oid);
198
+ }));
199
+ return upsertTree(writeRepo, parentTreeOid, blobs);
200
+ }
201
+ });
204
202
  }
203
+ /** Commit the removal of one file from a branch, straight to the bare repo. */
205
204
  async function deleteFileFromBare(repo, options) {
206
- if (!isSafeTreePath(options.filePath)) {
207
- throw new GitInvalidRequestError(`Invalid file path: ${options.filePath}`);
208
- }
209
- return writeCommitToBare(repo, {
210
- branch: options.branch,
211
- message: options.message,
212
- author: options.author,
213
- buildTree: async (parentTreeOid, writeRepo) => {
214
- if (!parentTreeOid) {
215
- throw new GitPathNotFoundError(`Branch ${options.branch} is empty`);
216
- }
217
- return deleteFromTree(writeRepo, parentTreeOid, options.filePath);
218
- }
219
- });
205
+ if (!isSafeTreePath(options.filePath)) throw new GitInvalidRequestError(`Invalid file path: ${options.filePath}`);
206
+ return writeCommitToBare(repo, {
207
+ branch: options.branch,
208
+ message: options.message,
209
+ author: options.author,
210
+ buildTree: async (parentTreeOid, writeRepo) => {
211
+ if (!parentTreeOid) throw new GitPathNotFoundError(`Branch ${options.branch} is empty`);
212
+ return deleteFromTree(writeRepo, parentTreeOid, options.filePath);
213
+ }
214
+ });
220
215
  }
221
-
222
- // src/ops/diff.ts
223
- import { createTwoFilesPatch } from "diff";
224
- import git2 from "isomorphic-git";
225
- var MAX_DIFF_TEXT_BYTES = 1024 * 1024;
226
- var MAX_CONCURRENT_READS = 16;
216
+ //#endregion
217
+ //#region src/ops/diff.ts
218
+ /** Text files larger than this get no patch (kind "too-large"). */
219
+ const MAX_DIFF_TEXT_BYTES = 1048576;
220
+ /**
221
+ * Blob reads and size probes one diff may have in flight at once. The walk
222
+ * visits a directory's entries concurrently, so without a bound a commit
223
+ * touching a wide tree issues one object-store request per entry at once; each
224
+ * read also materialises a whole blob. High enough to stay pipelined, low
225
+ * enough that a wide change can't exhaust sockets or memory from one request.
226
+ */
227
+ const MAX_CONCURRENT_READS = 16;
227
228
  function countContentLines(content) {
228
- if (content.length === 0) return 0;
229
- const lines = content.split("\n");
230
- if (lines.at(-1) === "") lines.pop();
231
- return lines.length;
229
+ if (content.length === 0) return 0;
230
+ const lines = content.split("\n");
231
+ if (lines.at(-1) === "") lines.pop();
232
+ return lines.length;
232
233
  }
233
234
  function unifiedPatch(path, before, after) {
234
- const body = createTwoFilesPatch(
235
- before ? `a/${path}` : "/dev/null",
236
- after ? `b/${path}` : "/dev/null",
237
- before?.text ?? "",
238
- after?.text ?? "",
239
- "",
240
- "",
241
- { context: 3 }
242
- ).replace(/^=+\n/, "");
243
- return `diff --git a/${path} b/${path}
244
- ${body}`;
235
+ return `diff --git a/${path} b/${path}\n${createTwoFilesPatch(before ? `a/${path}` : "/dev/null", after ? `b/${path}` : "/dev/null", before?.text ?? "", after?.text ?? "", "", "", { context: 3 }).replace(/^=+\n/, "")}`;
245
236
  }
237
+ /** One changed path: `before` null for an addition, `after` null for a deletion. */
246
238
  function buildDiffFile(path, before, after) {
247
- const kind = before?.isBinary || after?.isBinary ? "binary" : Math.max(before?.bytes.length ?? 0, after?.bytes.length ?? 0) > MAX_DIFF_TEXT_BYTES ? "too-large" : "text";
248
- const isText = kind === "text";
249
- return {
250
- path,
251
- status: !before ? "added" : !after ? "deleted" : "modified",
252
- kind,
253
- additions: isText && after ? countContentLines(after.text) : 0,
254
- deletions: isText && before ? countContentLines(before.text) : 0,
255
- patch: isText ? unifiedPatch(path, before, after) : "",
256
- oldSize: before?.bytes.length,
257
- newSize: after?.bytes.length
258
- };
239
+ const kind = before?.isBinary || after?.isBinary ? "binary" : Math.max(before?.bytes.length ?? 0, after?.bytes.length ?? 0) > 1048576 ? "too-large" : "text";
240
+ const isText = kind === "text";
241
+ return {
242
+ path,
243
+ status: !before ? "added" : !after ? "deleted" : "modified",
244
+ kind,
245
+ additions: isText && after ? countContentLines(after.text) : 0,
246
+ deletions: isText && before ? countContentLines(before.text) : 0,
247
+ patch: isText ? unifiedPatch(path, before, after) : "",
248
+ oldSize: before?.bytes.length,
249
+ newSize: after?.bytes.length
250
+ };
259
251
  }
260
252
  async function readDiffEntry(entry) {
261
- return entry ? {
262
- type: await entry.type(),
263
- oid: await entry.oid(),
264
- mode: await entry.mode()
265
- } : void 0;
253
+ return entry ? {
254
+ type: await entry.type(),
255
+ oid: await entry.oid(),
256
+ mode: await entry.mode()
257
+ } : void 0;
266
258
  }
259
+ /** Submodules are pointer diffs even when replacing a regular file or directory. */
267
260
  function buildSubmoduleDiff(filepath, before, after) {
268
- if (before?.type === after?.type && before?.oid === after?.oid)
269
- return void 0;
270
- const line = (entry) => entry ? `${entry.type === "commit" ? "Subproject commit" : entry.type === "tree" ? "Directory" : "File"} ${entry.oid}
271
- ` : "";
272
- return {
273
- path: filepath,
274
- status: !before ? "added" : !after ? "deleted" : "modified",
275
- kind: "submodule",
276
- additions: 0,
277
- deletions: 0,
278
- oldEntry: before,
279
- newEntry: after,
280
- patch: createTwoFilesPatch(
281
- before ? `a/${filepath}` : "/dev/null",
282
- after ? `b/${filepath}` : "/dev/null",
283
- line(before),
284
- line(after),
285
- "",
286
- "",
287
- { context: 3 }
288
- )
289
- };
261
+ if (before?.type === after?.type && before?.oid === after?.oid) return void 0;
262
+ const line = (entry) => entry ? `${entry.type === "commit" ? "Subproject commit" : entry.type === "tree" ? "Directory" : "File"} ${entry.oid}\n` : "";
263
+ return {
264
+ path: filepath,
265
+ status: !before ? "added" : !after ? "deleted" : "modified",
266
+ kind: "submodule",
267
+ additions: 0,
268
+ deletions: 0,
269
+ oldEntry: before,
270
+ newEntry: after,
271
+ patch: createTwoFilesPatch(before ? `a/${filepath}` : "/dev/null", after ? `b/${filepath}` : "/dev/null", line(before), line(after), "", "", { context: 3 })
272
+ };
290
273
  }
274
+ /**
275
+ * Every file that differs between two trees; `oldTreeOid` null diffs against
276
+ * nothing (a root commit: every file is an addition).
277
+ */
291
278
  async function diffTrees(repo, oldTreeOid, newTreeOid) {
292
- const reads = createSemaphore(MAX_CONCURRENT_READS);
293
- const sideMeta = async (entry) => {
294
- if (!entry || await entry.type() !== "blob") return null;
295
- const oid = await entry.oid();
296
- return { oid, size: await reads.run(() => getObjectSize(repo, oid)) };
297
- };
298
- const readContent = async (side) => {
299
- if (!side) return null;
300
- const { blob } = await reads.run(
301
- () => git2.readBlob({ ...repo, oid: side.oid })
302
- );
303
- return readBlobContent(blob);
304
- };
305
- const trees = oldTreeOid ? [git2.TREE({ ref: oldTreeOid }), git2.TREE({ ref: newTreeOid })] : [git2.TREE({ ref: newTreeOid })];
306
- const changes = await git2.walk({
307
- ...repo,
308
- trees,
309
- map: async (filepath, entries) => {
310
- const [oldEntry, newEntry] = oldTreeOid ? [entries[0] ?? null, entries[1] ?? null] : [null, entries[0] ?? null];
311
- const [oldType, newType] = await Promise.all([
312
- oldEntry?.type(),
313
- newEntry?.type()
314
- ]);
315
- if (oldType === "commit" || newType === "commit") {
316
- const [before2, after2] = await Promise.all([
317
- readDiffEntry(oldEntry),
318
- readDiffEntry(newEntry)
319
- ]);
320
- return buildSubmoduleDiff(filepath, before2, after2);
321
- }
322
- if (oldType !== "blob" && newType !== "blob") return void 0;
323
- if (oldEntry && newEntry && oldType === newType) {
324
- const [oldOid, newOid] = await Promise.all([
325
- oldEntry.oid(),
326
- newEntry.oid()
327
- ]);
328
- if (oldOid === newOid) return void 0;
329
- }
330
- const [oldSide, newSide] = await Promise.all([
331
- sideMeta(oldEntry),
332
- sideMeta(newEntry)
333
- ]);
334
- if ((oldSide?.size ?? 0) > MAX_DIFF_TEXT_BYTES || (newSide?.size ?? 0) > MAX_DIFF_TEXT_BYTES) {
335
- return {
336
- path: filepath,
337
- status: !oldSide ? "added" : !newSide ? "deleted" : "modified",
338
- kind: "too-large",
339
- additions: 0,
340
- deletions: 0,
341
- patch: "",
342
- oldSize: oldSide?.size ?? void 0,
343
- newSize: newSide?.size ?? void 0
344
- };
345
- }
346
- const [before, after] = await Promise.all([
347
- readContent(oldSide),
348
- readContent(newSide)
349
- ]);
350
- return before || after ? buildDiffFile(filepath, before, after) : void 0;
351
- }
352
- });
353
- const files = (changes ?? []).filter((file) => !!file);
354
- return {
355
- files,
356
- totalAdditions: files.reduce((sum, f) => sum + f.additions, 0),
357
- totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),
358
- totalFiles: files.length
359
- };
279
+ const reads = createSemaphore(MAX_CONCURRENT_READS);
280
+ /**
281
+ * A blob side's oid plus its size from its pack entry — asked before any
282
+ * content is read, so a file over {@link MAX_DIFF_TEXT_BYTES} is rejected
283
+ * without ever being inflated. `size` is `null` when the size isn't
284
+ * available (a loose object, or a store without range reads), in which case
285
+ * the caller reads the blob and the size check below still applies.
286
+ */
287
+ const sideMeta = async (entry) => {
288
+ if (!entry || await entry.type() !== "blob") return null;
289
+ const oid = await entry.oid();
290
+ return {
291
+ oid,
292
+ size: await reads.run(() => getObjectSize(repo, oid))
293
+ };
294
+ };
295
+ /** The side's content, or `null` when it isn't there. */
296
+ const readContent = async (side) => {
297
+ if (!side) return null;
298
+ const { blob } = await reads.run(() => git.readBlob({
299
+ ...repo,
300
+ oid: side.oid
301
+ }));
302
+ return readBlobContent(blob);
303
+ };
304
+ const trees = oldTreeOid ? [git.TREE({ ref: oldTreeOid }), git.TREE({ ref: newTreeOid })] : [git.TREE({ ref: newTreeOid })];
305
+ const files = (await git.walk({
306
+ ...repo,
307
+ trees,
308
+ map: async (filepath, entries) => {
309
+ const [oldEntry, newEntry] = oldTreeOid ? [entries[0] ?? null, entries[1] ?? null] : [null, entries[0] ?? null];
310
+ const [oldType, newType] = await Promise.all([oldEntry?.type(), newEntry?.type()]);
311
+ if (oldType === "commit" || newType === "commit") {
312
+ const [before, after] = await Promise.all([readDiffEntry(oldEntry), readDiffEntry(newEntry)]);
313
+ return buildSubmoduleDiff(filepath, before, after);
314
+ }
315
+ if (oldType !== "blob" && newType !== "blob") return void 0;
316
+ if (oldEntry && newEntry && oldType === newType) {
317
+ const [oldOid, newOid] = await Promise.all([oldEntry.oid(), newEntry.oid()]);
318
+ if (oldOid === newOid) return void 0;
319
+ }
320
+ const [oldSide, newSide] = await Promise.all([sideMeta(oldEntry), sideMeta(newEntry)]);
321
+ if ((oldSide?.size ?? 0) > 1048576 || (newSide?.size ?? 0) > 1048576) return {
322
+ path: filepath,
323
+ status: !oldSide ? "added" : !newSide ? "deleted" : "modified",
324
+ kind: "too-large",
325
+ additions: 0,
326
+ deletions: 0,
327
+ patch: "",
328
+ oldSize: oldSide?.size ?? void 0,
329
+ newSize: newSide?.size ?? void 0
330
+ };
331
+ const [before, after] = await Promise.all([readContent(oldSide), readContent(newSide)]);
332
+ return before || after ? buildDiffFile(filepath, before, after) : void 0;
333
+ }
334
+ }) ?? []).filter((file) => !!file);
335
+ return {
336
+ files,
337
+ totalAdditions: files.reduce((sum, f) => sum + f.additions, 0),
338
+ totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),
339
+ totalFiles: files.length
340
+ };
360
341
  }
342
+ /** The diff a single commit introduced (against its first parent). */
361
343
  async function getCommitDiff(repo, commitSha) {
362
- const { commit } = await getCommit(repo, commitSha);
363
- const parent = commit.parent[0];
364
- const parentTree = parent ? (await getCommit(repo, parent)).commit.tree : null;
365
- return {
366
- ...await diffTrees(repo, parentTree, commit.tree),
367
- oldRef: parent,
368
- newRef: commitSha
369
- };
344
+ const { commit } = await getCommit(repo, commitSha);
345
+ const parent = commit.parent[0];
346
+ return {
347
+ ...await diffTrees(repo, parent ? (await getCommit(repo, parent)).commit.tree : null, commit.tree),
348
+ oldRef: parent,
349
+ newRef: commitSha
350
+ };
370
351
  }
352
+ /**
353
+ * What `compareRef` changed since it diverged from `baseRef` — a pull
354
+ * request's diff (git's three-dot `base...compare`): compared against their
355
+ * merge base, so commits that landed on `baseRef` since the branch point
356
+ * don't show up as reverted. Throws GitRefNotFoundError for a missing ref.
357
+ */
371
358
  async function getDiffBetweenRefs(repo, baseRef, compareRef) {
372
- const [base, compare] = await Promise.all([
373
- resolveCommit(repo, baseRef),
374
- resolveCommit(repo, compareRef)
375
- ]);
376
- const [mergeBase] = await git2.findMergeBase({
377
- ...repo,
378
- oids: [base.oid, compare.oid]
379
- });
380
- const baseTree = mergeBase === void 0 ? null : mergeBase === base.oid ? base.commit.tree : (await getCommit(repo, mergeBase)).commit.tree;
381
- return {
382
- ...await diffTrees(repo, baseTree, compare.commit.tree),
383
- oldRef: mergeBase,
384
- newRef: compare.oid
385
- };
359
+ const [base, compare] = await Promise.all([resolveCommit(repo, baseRef), resolveCommit(repo, compareRef)]);
360
+ const [mergeBase] = await git.findMergeBase({
361
+ ...repo,
362
+ oids: [base.oid, compare.oid]
363
+ });
364
+ return {
365
+ ...await diffTrees(repo, mergeBase === void 0 ? null : mergeBase === base.oid ? base.commit.tree : (await getCommit(repo, mergeBase)).commit.tree, compare.commit.tree),
366
+ oldRef: mergeBase,
367
+ newRef: compare.oid
368
+ };
386
369
  }
387
-
388
- // src/ops/file-history.ts
389
- var BANNER_WALK_DEPTH = 60;
390
- var PREFETCH_WINDOW = 24;
370
+ //#endregion
371
+ //#region src/ops/file-history.ts
372
+ /**
373
+ * A shallow walk for a "latest commit touching this file" banner, which only
374
+ * shows `entries[0]`; a full History view is a click away.
375
+ */
376
+ const BANNER_WALK_DEPTH = 60;
377
+ /** Tree reads are prefetched in parallel windows; see getLastCommitsForTree. */
378
+ const PREFETCH_WINDOW$1 = 24;
379
+ /**
380
+ * All commits (newest first) that changed a single file's blob oid, walking
381
+ * the first-parent chain — same approach as getLastCommitsForTree but for one
382
+ * path and collecting every match instead of stopping at the first.
383
+ */
391
384
  async function getFileHistory(repo, options, hooks) {
392
- const limit = options.limit ?? 30;
393
- const maxDepth = options.maxDepth ?? HISTORY_WALK_DEPTH;
394
- const walkDepth = Math.max(maxDepth, limit);
395
- const commits = await runStep(
396
- hooks,
397
- `getCommitLog depth=${walkDepth}`,
398
- () => getCommitLog(repo, { ref: options.ref, depth: walkDepth }, hooks)
399
- );
400
- const head = commits[0];
401
- if (!head) return { entries: [], truncated: false };
402
- const cacheKey = `file-history:${repo.gitdir}:${head.oid}:${options.filePath}:${limit}:${maxDepth}`;
403
- const cached = hooks?.resultCache?.get(cacheKey);
404
- if (cached) {
405
- hooks?.onNote?.("getFileHistory: result-cache HIT, skipping history walk");
406
- return cached;
407
- }
408
- hooks?.onNote?.("getFileHistory: result-cache MISS, walking history");
409
- const byOid = new Map(commits.map((commit) => [commit.oid, commit]));
410
- const oidByCommitTree = /* @__PURE__ */ new Map();
411
- async function resolveOid(commitTreeOid) {
412
- const cachedOid = oidByCommitTree.get(commitTreeOid);
413
- if (cachedOid !== void 0) return cachedOid;
414
- const entry = await findTreeEntry(repo, commitTreeOid, options.filePath);
415
- const oid = entry && entry.type !== "tree" ? entry.oid : null;
416
- oidByCommitTree.set(commitTreeOid, oid);
417
- return oid;
418
- }
419
- const entries = [];
420
- let truncated = false;
421
- outer: for (let windowStart = 0; windowStart < commits.length; windowStart += PREFETCH_WINDOW) {
422
- const windowEnd = Math.min(windowStart + PREFETCH_WINDOW, commits.length);
423
- const prefetchEnd = Math.min(windowEnd + 1, commits.length);
424
- await Promise.all(
425
- commits.slice(windowStart, prefetchEnd).map((commit) => resolveOid(commit.commit.tree))
426
- );
427
- for (let i = windowStart; i < windowEnd; i++) {
428
- const commit = commits[i];
429
- if (!commit) break outer;
430
- const parentSha = commit.commit.parent[0];
431
- const parentCommit = parentSha ? byOid.get(parentSha) : void 0;
432
- if (parentSha && !parentCommit) {
433
- truncated = true;
434
- break outer;
435
- }
436
- const [oid, parentOid] = await Promise.all([
437
- resolveOid(commit.commit.tree),
438
- parentCommit ? resolveOid(parentCommit.commit.tree) : Promise.resolve(null)
439
- ]);
440
- if (oid !== parentOid) {
441
- entries.push(summarizeCommit(commit));
442
- if (entries.length >= limit) {
443
- truncated = i < commits.length - 1;
444
- break outer;
445
- }
446
- }
447
- }
448
- }
449
- const result = { entries, truncated };
450
- hooks?.resultCache?.set(cacheKey, result);
451
- return result;
385
+ const limit = options.limit ?? 30;
386
+ const maxDepth = options.maxDepth ?? 400;
387
+ const walkDepth = Math.max(maxDepth, limit);
388
+ const commits = await runStep(hooks, `getCommitLog depth=${walkDepth}`, () => getCommitLog(repo, {
389
+ ref: options.ref,
390
+ depth: walkDepth
391
+ }, hooks));
392
+ const head = commits[0];
393
+ if (!head) return {
394
+ entries: [],
395
+ truncated: false
396
+ };
397
+ const cacheKey = `file-history:${repo.gitdir}:${head.oid}:${options.filePath}:${limit}:${maxDepth}`;
398
+ const cached = hooks?.resultCache?.get(cacheKey);
399
+ if (cached) {
400
+ hooks?.onNote?.("getFileHistory: result-cache HIT, skipping history walk");
401
+ return cached;
402
+ }
403
+ hooks?.onNote?.("getFileHistory: result-cache MISS, walking history");
404
+ const byOid = new Map(commits.map((commit) => [commit.oid, commit]));
405
+ const oidByCommitTree = /* @__PURE__ */ new Map();
406
+ async function resolveOid(commitTreeOid) {
407
+ const cachedOid = oidByCommitTree.get(commitTreeOid);
408
+ if (cachedOid !== void 0) return cachedOid;
409
+ const entry = await findTreeEntry(repo, commitTreeOid, options.filePath);
410
+ const oid = entry && entry.type !== "tree" ? entry.oid : null;
411
+ oidByCommitTree.set(commitTreeOid, oid);
412
+ return oid;
413
+ }
414
+ const entries = [];
415
+ let truncated = false;
416
+ outer: for (let windowStart = 0; windowStart < commits.length; windowStart += PREFETCH_WINDOW$1) {
417
+ const windowEnd = Math.min(windowStart + PREFETCH_WINDOW$1, commits.length);
418
+ const prefetchEnd = Math.min(windowEnd + 1, commits.length);
419
+ await Promise.all(commits.slice(windowStart, prefetchEnd).map((commit) => resolveOid(commit.commit.tree)));
420
+ for (let i = windowStart; i < windowEnd; i++) {
421
+ const commit = commits[i];
422
+ if (!commit) break outer;
423
+ const parentSha = commit.commit.parent[0];
424
+ const parentCommit = parentSha ? byOid.get(parentSha) : void 0;
425
+ if (parentSha && !parentCommit) {
426
+ truncated = true;
427
+ break outer;
428
+ }
429
+ const [oid, parentOid] = await Promise.all([resolveOid(commit.commit.tree), parentCommit ? resolveOid(parentCommit.commit.tree) : Promise.resolve(null)]);
430
+ if (oid !== parentOid) {
431
+ entries.push(summarizeCommit(commit));
432
+ if (entries.length >= limit) {
433
+ truncated = i < commits.length - 1;
434
+ break outer;
435
+ }
436
+ }
437
+ }
438
+ }
439
+ const result = {
440
+ entries,
441
+ truncated
442
+ };
443
+ hooks?.resultCache?.set(cacheKey, result);
444
+ return result;
452
445
  }
453
-
454
- // src/ops/last-commit.ts
455
- var PREFETCH_WINDOW2 = 24;
446
+ //#endregion
447
+ //#region src/ops/last-commit.ts
448
+ /**
449
+ * The walk itself is inherently sequential (each step needs to know what's
450
+ * still "remaining" from the step before), but the tree-object reads that
451
+ * back it are not — each commit's tree oid is already known upfront from the
452
+ * commit log. Prefetching a window of tree reads in parallel turns ~1 network
453
+ * round trip per commit (serialized) into ~1 round trip per window, which is
454
+ * where nearly all of this function's wall-clock time goes on object storage.
455
+ */
456
+ const PREFETCH_WINDOW = 24;
457
+ /**
458
+ * For each direct child of `treePath` (at the tip of `ref`), find the most
459
+ * recent commit that changed it — the tree view's "last commit" column. Walks
460
+ * history newest-to-oldest, comparing the directory's tree oid
461
+ * commit-to-commit and only descending one level to diff child oids when
462
+ * something under the directory actually changed. Preserve the two-phase
463
+ * structure (parallel prefetch, then sequential resolve) when touching this —
464
+ * the "which entries are still unresolved" state must advance
465
+ * commit-by-commit.
466
+ */
456
467
  async function getLastCommitsForTree(repo, options, hooks) {
457
- const treePath = options.treePath ?? "";
458
- const depth = options.depth ?? HISTORY_WALK_DEPTH;
459
- const commits = await runStep(
460
- hooks,
461
- `getCommitLog depth=${depth}`,
462
- () => getCommitLog(repo, { ref: options.ref, depth }, hooks)
463
- );
464
- hooks?.onNote?.(`getLastCommitsForTree: ${commits.length} commits in log`);
465
- const head = commits[0];
466
- if (!head) return {};
467
- const cacheKey = `last-commits:${repo.gitdir}:${head.oid}:${treePath}`;
468
- const cachedResult = hooks?.resultCache?.get(cacheKey);
469
- if (cachedResult) {
470
- hooks?.onNote?.(
471
- "getLastCommitsForTree: result-cache HIT, skipping history walk"
472
- );
473
- return cachedResult;
474
- }
475
- hooks?.onNote?.("getLastCommitsForTree: result-cache MISS, walking history");
476
- const byOid = new Map(commits.map((commit) => [commit.oid, commit]));
477
- const dirOidByCommitTree = /* @__PURE__ */ new Map();
478
- const childrenByDirOid = /* @__PURE__ */ new Map();
479
- async function resolveDirOid(commitTreeOid) {
480
- const cached = dirOidByCommitTree.get(commitTreeOid);
481
- if (cached !== void 0) return cached;
482
- const entry = await findTreeEntry(repo, commitTreeOid, treePath);
483
- const dirOid = entry?.type === "tree" ? entry.oid : null;
484
- dirOidByCommitTree.set(commitTreeOid, dirOid);
485
- return dirOid;
486
- }
487
- async function resolveChildren(dirOid) {
488
- if (dirOid === null) return [];
489
- const cached = childrenByDirOid.get(dirOid);
490
- if (cached) return cached;
491
- const children = await listTreeEntries(repo, dirOid, treePath);
492
- childrenByDirOid.set(dirOid, children);
493
- return children;
494
- }
495
- const headDirOid = await resolveDirOid(head.commit.tree);
496
- if (headDirOid === null) return {};
497
- const headChildren = await resolveChildren(headDirOid);
498
- const remaining = new Set(headChildren.map((entry) => entry.path));
499
- const result = {};
500
- let commitsWalked = 0;
501
- let prefetchWindows = 0;
502
- const walkStart = performance.now();
503
- outer: for (let windowStart = 0; windowStart < commits.length && remaining.size > 0; windowStart += PREFETCH_WINDOW2) {
504
- const windowEnd = Math.min(windowStart + PREFETCH_WINDOW2, commits.length);
505
- const prefetchEnd = Math.min(windowEnd + 1, commits.length);
506
- prefetchWindows++;
507
- const dirOids = await Promise.all(
508
- commits.slice(windowStart, prefetchEnd).map((commit) => resolveDirOid(commit.commit.tree))
509
- );
510
- await Promise.all(
511
- [...new Set(dirOids)].map((dirOid) => resolveChildren(dirOid))
512
- );
513
- for (let i = windowStart; i < windowEnd; i++) {
514
- if (remaining.size === 0) break outer;
515
- const commit = commits[i];
516
- if (!commit) break outer;
517
- commitsWalked++;
518
- const parentSha = commit.commit.parent[0];
519
- const parentCommit = parentSha ? byOid.get(parentSha) : void 0;
520
- if (parentSha && !parentCommit) break outer;
521
- const [dirOid, parentDirOid] = await Promise.all([
522
- resolveDirOid(commit.commit.tree),
523
- parentCommit ? resolveDirOid(parentCommit.commit.tree) : Promise.resolve(null)
524
- ]);
525
- if (dirOid === parentDirOid) continue;
526
- const [children, parentChildren] = await Promise.all([
527
- resolveChildren(dirOid),
528
- resolveChildren(parentDirOid)
529
- ]);
530
- const childByName = new Map(
531
- children.map((entry) => [entry.path, entry.oid])
532
- );
533
- const parentChildByName = new Map(
534
- parentChildren.map((entry) => [entry.path, entry.oid])
535
- );
536
- for (const name of remaining) {
537
- if (childByName.get(name) !== parentChildByName.get(name)) {
538
- result[name] = summarizeCommit(commit);
539
- }
540
- }
541
- for (const name of Object.keys(result)) {
542
- remaining.delete(name);
543
- }
544
- }
545
- }
546
- hooks?.onNote?.(
547
- `getLastCommitsForTree: walked ${commitsWalked}/${commits.length} commits across ${prefetchWindows} prefetch windows in ${(performance.now() - walkStart).toFixed(1)}ms, ${dirOidByCommitTree.size} unique tree lookups, ${remaining.size} entries never resolved`
548
- );
549
- hooks?.resultCache?.set(cacheKey, result);
550
- return result;
468
+ const treePath = options.treePath ?? "";
469
+ const depth = options.depth ?? 400;
470
+ const commits = await runStep(hooks, `getCommitLog depth=${depth}`, () => getCommitLog(repo, {
471
+ ref: options.ref,
472
+ depth
473
+ }, hooks));
474
+ hooks?.onNote?.(`getLastCommitsForTree: ${commits.length} commits in log`);
475
+ const head = commits[0];
476
+ if (!head) return {};
477
+ const cacheKey = `last-commits:${repo.gitdir}:${head.oid}:${treePath}`;
478
+ const cachedResult = hooks?.resultCache?.get(cacheKey);
479
+ if (cachedResult) {
480
+ hooks?.onNote?.("getLastCommitsForTree: result-cache HIT, skipping history walk");
481
+ return cachedResult;
482
+ }
483
+ hooks?.onNote?.("getLastCommitsForTree: result-cache MISS, walking history");
484
+ const byOid = new Map(commits.map((commit) => [commit.oid, commit]));
485
+ const dirOidByCommitTree = /* @__PURE__ */ new Map();
486
+ const childrenByDirOid = /* @__PURE__ */ new Map();
487
+ async function resolveDirOid(commitTreeOid) {
488
+ const cached = dirOidByCommitTree.get(commitTreeOid);
489
+ if (cached !== void 0) return cached;
490
+ const entry = await findTreeEntry(repo, commitTreeOid, treePath);
491
+ const dirOid = entry?.type === "tree" ? entry.oid : null;
492
+ dirOidByCommitTree.set(commitTreeOid, dirOid);
493
+ return dirOid;
494
+ }
495
+ async function resolveChildren(dirOid) {
496
+ if (dirOid === null) return [];
497
+ const cached = childrenByDirOid.get(dirOid);
498
+ if (cached) return cached;
499
+ const children = await listTreeEntries(repo, dirOid, treePath);
500
+ childrenByDirOid.set(dirOid, children);
501
+ return children;
502
+ }
503
+ const headDirOid = await resolveDirOid(head.commit.tree);
504
+ if (headDirOid === null) return {};
505
+ const headChildren = await resolveChildren(headDirOid);
506
+ const remaining = new Set(headChildren.map((entry) => entry.path));
507
+ const result = {};
508
+ let commitsWalked = 0;
509
+ let prefetchWindows = 0;
510
+ const walkStart = performance.now();
511
+ outer: for (let windowStart = 0; windowStart < commits.length && remaining.size > 0; windowStart += PREFETCH_WINDOW) {
512
+ const windowEnd = Math.min(windowStart + PREFETCH_WINDOW, commits.length);
513
+ const prefetchEnd = Math.min(windowEnd + 1, commits.length);
514
+ prefetchWindows++;
515
+ const dirOids = await Promise.all(commits.slice(windowStart, prefetchEnd).map((commit) => resolveDirOid(commit.commit.tree)));
516
+ await Promise.all([...new Set(dirOids)].map((dirOid) => resolveChildren(dirOid)));
517
+ for (let i = windowStart; i < windowEnd; i++) {
518
+ if (remaining.size === 0) break outer;
519
+ const commit = commits[i];
520
+ if (!commit) break outer;
521
+ commitsWalked++;
522
+ const parentSha = commit.commit.parent[0];
523
+ const parentCommit = parentSha ? byOid.get(parentSha) : void 0;
524
+ if (parentSha && !parentCommit) break outer;
525
+ const [dirOid, parentDirOid] = await Promise.all([resolveDirOid(commit.commit.tree), parentCommit ? resolveDirOid(parentCommit.commit.tree) : Promise.resolve(null)]);
526
+ if (dirOid === parentDirOid) continue;
527
+ const [children, parentChildren] = await Promise.all([resolveChildren(dirOid), resolveChildren(parentDirOid)]);
528
+ const childByName = new Map(children.map((entry) => [entry.path, entry.oid]));
529
+ const parentChildByName = new Map(parentChildren.map((entry) => [entry.path, entry.oid]));
530
+ for (const name of remaining) if (childByName.get(name) !== parentChildByName.get(name)) result[name] = summarizeCommit(commit);
531
+ for (const name of Object.keys(result)) remaining.delete(name);
532
+ }
533
+ }
534
+ hooks?.onNote?.(`getLastCommitsForTree: walked ${commitsWalked}/${commits.length} commits across ${prefetchWindows} prefetch windows in ${(performance.now() - walkStart).toFixed(1)}ms, ${dirOidByCommitTree.size} unique tree lookups, ${remaining.size} entries never resolved`);
535
+ hooks?.resultCache?.set(cacheKey, result);
536
+ return result;
551
537
  }
552
-
553
- // src/ops/merge.ts
554
- import git3 from "isomorphic-git";
538
+ //#endregion
539
+ //#region src/ops/merge.ts
540
+ /**
541
+ * Cheap pre-merge check: do both branches exist, and is this a fast-forward?
542
+ * `canMerge`/`fastForward` are the only fields this actually determines —
543
+ * `hasConflicts`/`conflictingFiles` are NOT a real content-conflict check
544
+ * (isomorphic-git's `git.merge` doesn't expose a dry-run), they only ever
545
+ * reflect "one of the branches couldn't be resolved" (`canMerge: false`).
546
+ * Real merge conflicts are only discoverable by actually attempting the
547
+ * merge.
548
+ */
555
549
  async function analyzeMerge(repo, sourceBranch, targetBranch) {
556
- assertSafeBranchName(sourceBranch);
557
- assertSafeBranchName(targetBranch);
558
- try {
559
- const [sourceOid, targetOid] = await Promise.all([
560
- git3.resolveRef({ ...repo, ref: qualifyBranchRef(sourceBranch) }),
561
- git3.resolveRef({ ...repo, ref: qualifyBranchRef(targetBranch) })
562
- ]);
563
- const isDescendant = sourceOid === targetOid || await git3.isDescendent({
564
- ...repo,
565
- oid: sourceOid,
566
- ancestor: targetOid
567
- });
568
- return {
569
- canMerge: true,
570
- hasConflicts: false,
571
- conflictingFiles: [],
572
- fastForward: isDescendant
573
- };
574
- } catch (err) {
575
- if (err?.code !== "NotFoundError") {
576
- throw err;
577
- }
578
- return {
579
- canMerge: false,
580
- hasConflicts: true,
581
- conflictingFiles: [],
582
- fastForward: false
583
- };
584
- }
550
+ assertSafeBranchName(sourceBranch);
551
+ assertSafeBranchName(targetBranch);
552
+ try {
553
+ const [sourceOid, targetOid] = await Promise.all([git.resolveRef({
554
+ ...repo,
555
+ ref: qualifyBranchRef(sourceBranch)
556
+ }), git.resolveRef({
557
+ ...repo,
558
+ ref: qualifyBranchRef(targetBranch)
559
+ })]);
560
+ return {
561
+ canMerge: true,
562
+ hasConflicts: false,
563
+ conflictingFiles: [],
564
+ fastForward: sourceOid === targetOid || await git.isDescendent({
565
+ ...repo,
566
+ oid: sourceOid,
567
+ ancestor: targetOid
568
+ })
569
+ };
570
+ } catch (err) {
571
+ if (err?.code !== "NotFoundError") throw err;
572
+ return {
573
+ canMerge: false,
574
+ hasConflicts: true,
575
+ conflictingFiles: [],
576
+ fastForward: false
577
+ };
578
+ }
585
579
  }
580
+ /**
581
+ * Attempt a fast-forward merge directly against the bare repo: when source is
582
+ * a descendant of target, just move the target ref — no worktree, no new
583
+ * commit. Returns null when the merge is not a fast-forward (callers fall
584
+ * back to a real three-way merge, which needs a worktree). Serialize with a
585
+ * per-repo lock: resolve → writeRef is not atomic.
586
+ */
586
587
  async function fastForwardMerge(repo, sourceBranch, targetBranch) {
587
- assertSafeBranchName(sourceBranch);
588
- assertSafeBranchName(targetBranch);
589
- const [sourceOid, targetOid] = await Promise.all([
590
- git3.resolveRef({ ...repo, ref: `refs/heads/${sourceBranch}` }),
591
- git3.resolveRef({ ...repo, ref: `refs/heads/${targetBranch}` })
592
- ]);
593
- if (sourceOid === targetOid || await git3.isDescendent({ ...repo, oid: targetOid, ancestor: sourceOid })) {
594
- return { success: true, commitSha: targetOid };
595
- }
596
- const isFF = await git3.isDescendent({
597
- ...repo,
598
- oid: sourceOid,
599
- ancestor: targetOid
600
- });
601
- if (!isFF) return null;
602
- await git3.writeRef({
603
- ...repo,
604
- ref: `refs/heads/${targetBranch}`,
605
- value: sourceOid,
606
- force: true
607
- });
608
- return { success: true, commitSha: sourceOid };
588
+ assertSafeBranchName(sourceBranch);
589
+ assertSafeBranchName(targetBranch);
590
+ const [sourceOid, targetOid] = await Promise.all([git.resolveRef({
591
+ ...repo,
592
+ ref: `refs/heads/${sourceBranch}`
593
+ }), git.resolveRef({
594
+ ...repo,
595
+ ref: `refs/heads/${targetBranch}`
596
+ })]);
597
+ if (sourceOid === targetOid || await git.isDescendent({
598
+ ...repo,
599
+ oid: targetOid,
600
+ ancestor: sourceOid
601
+ })) return {
602
+ success: true,
603
+ commitSha: targetOid
604
+ };
605
+ if (!await git.isDescendent({
606
+ ...repo,
607
+ oid: sourceOid,
608
+ ancestor: targetOid
609
+ })) return null;
610
+ await git.writeRef({
611
+ ...repo,
612
+ ref: `refs/heads/${targetBranch}`,
613
+ value: sourceOid,
614
+ force: true
615
+ });
616
+ return {
617
+ success: true,
618
+ commitSha: sourceOid
619
+ };
609
620
  }
610
-
611
- // src/ops/submodules.ts
621
+ //#endregion
622
+ //#region src/ops/submodules.ts
623
+ /** Git config values: comments outside quotes, quoted whitespace, and Git's escapes. */
612
624
  function parseValue(raw) {
613
- let value = "";
614
- let spaces = "";
615
- let quoted = false;
616
- const escapes = {
617
- n: "\n",
618
- t: " ",
619
- b: "\b",
620
- "\\": "\\",
621
- '"': '"'
622
- };
623
- for (let i = 0; i < raw.length; i++) {
624
- const c = raw.charAt(i);
625
- if (c === "\\") {
626
- const escaped = escapes[raw.charAt(++i)];
627
- if (escaped === void 0) return null;
628
- value += spaces + escaped;
629
- spaces = "";
630
- } else if (c === '"') {
631
- value += spaces;
632
- spaces = "";
633
- quoted = !quoted;
634
- } else if (!quoted && (c === "#" || c === ";")) break;
635
- else if (!quoted && /\s/.test(c)) {
636
- if (value) spaces += c;
637
- } else {
638
- value += spaces + c;
639
- spaces = "";
640
- }
641
- }
642
- return quoted ? null : value;
625
+ let value = "";
626
+ let spaces = "";
627
+ let quoted = false;
628
+ const escapes = {
629
+ n: "\n",
630
+ t: " ",
631
+ b: "\b",
632
+ "\\": "\\",
633
+ "\"": "\""
634
+ };
635
+ for (let i = 0; i < raw.length; i++) {
636
+ const c = raw.charAt(i);
637
+ if (c === "\\") {
638
+ const escaped = escapes[raw.charAt(++i)];
639
+ if (escaped === void 0) return null;
640
+ value += spaces + escaped;
641
+ spaces = "";
642
+ } else if (c === "\"") {
643
+ value += spaces;
644
+ spaces = "";
645
+ quoted = !quoted;
646
+ } else if (!quoted && (c === "#" || c === ";")) break;
647
+ else if (!quoted && /\s/.test(c)) {
648
+ if (value) spaces += c;
649
+ } else {
650
+ value += spaces + c;
651
+ spaces = "";
652
+ }
653
+ }
654
+ return quoted ? null : value;
643
655
  }
656
+ /**
657
+ * Read only submodule path/url records. Never expands includes, executes Git
658
+ * commands, or fetches URLs. Ambiguous paths and malformed records are ignored.
659
+ */
644
660
  function parseGitmodules(content) {
645
- const sections = /* @__PURE__ */ new Map();
646
- let current;
647
- const lines = [];
648
- let pending = "";
649
- for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
650
- const slashes = /\\+$/.exec(line)?.[0].length ?? 0;
651
- if (slashes % 2 === 1) pending += line.slice(0, -1);
652
- else {
653
- lines.push(pending + line);
654
- pending = "";
655
- }
656
- }
657
- for (const line of lines) {
658
- const trimmed = line.trim();
659
- if (!trimmed || /^[#;]/.test(trimmed)) continue;
660
- if (trimmed.startsWith("[")) {
661
- const match = /^\[submodule\s+"((?:[^"\\]|\\["\\])*)"\]\s*(?:[#;].*)?$/i.exec(
662
- trimmed
663
- );
664
- current = void 0;
665
- if (!match?.[1]) continue;
666
- const name = match[1].replace(/\\(["\\])/g, "$1");
667
- current = sections.get(name) ?? { name };
668
- sections.set(name, current);
669
- continue;
670
- }
671
- if (!current) continue;
672
- const field = /^(path|url)\s*=\s*(.*)$/i.exec(trimmed);
673
- if (!field?.[1] || field[2] === void 0) continue;
674
- const key = field[1].toLowerCase();
675
- const value = parseValue(field[2]);
676
- if (value === null) delete current[key];
677
- else current[key] = value;
678
- }
679
- const valid = [...sections.values()].filter(
680
- (s) => !!s.name && !!s.path && !!s.url && ![...s.path + s.url].some(
681
- (c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127
682
- ) && !s.path.startsWith("/") && !s.path.includes("\\") && s.path.split("/").every(
683
- (p) => p !== "" && p !== "." && p !== ".." && p.toLowerCase() !== ".git"
684
- )
685
- );
686
- const counts = /* @__PURE__ */ new Map();
687
- for (const s of valid) counts.set(s.path, (counts.get(s.path) ?? 0) + 1);
688
- return valid.filter((s) => counts.get(s.path) === 1);
661
+ const sections = /* @__PURE__ */ new Map();
662
+ let current;
663
+ const lines = [];
664
+ let pending = "";
665
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) if ((/\\+$/.exec(line)?.[0].length ?? 0) % 2 === 1) pending += line.slice(0, -1);
666
+ else {
667
+ lines.push(pending + line);
668
+ pending = "";
669
+ }
670
+ for (const line of lines) {
671
+ const trimmed = line.trim();
672
+ if (!trimmed || /^[#;]/.test(trimmed)) continue;
673
+ if (trimmed.startsWith("[")) {
674
+ const match = /^\[submodule\s+"((?:[^"\\]|\\["\\])*)"\]\s*(?:[#;].*)?$/i.exec(trimmed);
675
+ current = void 0;
676
+ if (!match?.[1]) continue;
677
+ const name = match[1].replace(/\\(["\\])/g, "$1");
678
+ current = sections.get(name) ?? { name };
679
+ sections.set(name, current);
680
+ continue;
681
+ }
682
+ if (!current) continue;
683
+ const field = /^(path|url)\s*=\s*(.*)$/i.exec(trimmed);
684
+ if (!field?.[1] || field[2] === void 0) continue;
685
+ const key = field[1].toLowerCase();
686
+ const value = parseValue(field[2]);
687
+ if (value === null) delete current[key];
688
+ else current[key] = value;
689
+ }
690
+ const valid = [...sections.values()].filter((s) => !!s.name && !!s.path && !!s.url && ![...s.path + s.url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) && !s.path.startsWith("/") && !s.path.includes("\\") && s.path.split("/").every((p) => p !== "" && p !== "." && p !== ".." && p.toLowerCase() !== ".git"));
691
+ const counts = /* @__PURE__ */ new Map();
692
+ for (const s of valid) counts.set(s.path, (counts.get(s.path) ?? 0) + 1);
693
+ return valid.filter((s) => counts.get(s.path) === 1);
689
694
  }
695
+ /** The root .gitmodules at this exact revision; absent/oversized config yields no links. */
690
696
  async function getSubmodules(repo, ref, hooks) {
691
- let bytes;
692
- try {
693
- bytes = await getFileContent(repo, ".gitmodules", ref, hooks);
694
- } catch (error) {
695
- if (error instanceof GitPathNotFoundError) return [];
696
- throw error;
697
- }
698
- if (bytes.length > 1024 * 1024) return [];
699
- return parseGitmodules(new TextDecoder().decode(bytes));
697
+ let bytes;
698
+ try {
699
+ bytes = await getFileContent(repo, ".gitmodules", ref, hooks);
700
+ } catch (error) {
701
+ if (error instanceof GitPathNotFoundError) return [];
702
+ throw error;
703
+ }
704
+ if (bytes.length > 1048576) return [];
705
+ return parseGitmodules(new TextDecoder().decode(bytes));
700
706
  }
701
- export {
702
- BANNER_WALK_DEPTH,
703
- HISTORY_WALK_DEPTH,
704
- MAX_DIFF_TEXT_BYTES,
705
- analyzeMerge,
706
- assertBranchExists,
707
- assertSafeBranchName,
708
- authorNow,
709
- commitFilesToBare,
710
- createBranchFrom,
711
- deleteBranchByName,
712
- deleteFileFromBare,
713
- deleteFromTree,
714
- fastForwardMerge,
715
- findTreeEntry,
716
- getBlob,
717
- getCommit,
718
- getCommitDiff,
719
- getCommitHistory,
720
- getCommitLog,
721
- getDiffBetweenRefs,
722
- getFileContent,
723
- getFileHistory,
724
- getLastCommitsForTree,
725
- getSubmodules,
726
- getTreeFromRef,
727
- listBranches,
728
- listTags,
729
- listTreeEntries,
730
- parseGitmodules,
731
- peelToCommit,
732
- resolveCommit,
733
- resolveLooseRefFast,
734
- resultKeyPrefixes,
735
- summarizeCommit,
736
- upsertTree,
737
- withPackedWrites,
738
- writeCommitToBare
739
- };
707
+ //#endregion
708
+ export { BANNER_WALK_DEPTH, HISTORY_WALK_DEPTH, MAX_DIFF_TEXT_BYTES, analyzeMerge, assertBranchExists, assertSafeBranchName, authorNow, commitFilesToBare, createBranchFrom, deleteBranchByName, deleteFileFromBare, deleteFromTree, fastForwardMerge, findTreeEntry, getBlob, getCommit, getCommitDiff, getCommitHistory, getCommitLog, getDiffBetweenRefs, getFileContent, getFileHistory, getLastCommitsForTree, getSubmodules, getTreeFromRef, listBranches, listTags, listTreeEntries, parseGitmodules, peelToCommit, resolveCommit, resolveLooseRefFast, resultKeyPrefixes, summarizeCommit, upsertTree, withPackedWrites, writeCommitToBare };
709
+
740
710
  //# sourceMappingURL=ops.js.map