git-fs-s3 0.15.0 → 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 -71
- 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 +18 -23
- 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/ops.js
CHANGED
|
@@ -1,740 +1,710 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/ops/commit.ts
|
|
112
|
+
/** An author stamped with the current time. */
|
|
128
113
|
function authorNow(name, email) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
134
|
+
assertSafeBranchName(options.branch);
|
|
135
|
+
return withPackedWrites(repo, (packed) => writeCommit(packed, options));
|
|
139
136
|
}
|
|
140
137
|
async function writeCommit(repo, options) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|