@orion-agents/orion-code 0.3.14 → 0.3.17
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/CHANGELOG.md +286 -0
- package/README.md +6 -8
- package/README.zh-CN.md +4 -5
- package/dist/runtime/release-receipts.d.ts +15 -3
- package/dist/runtime/release-receipts.js +27 -1
- package/dist/services/session-storage.d.ts +9 -0
- package/dist/services/session-storage.js +27 -1
- package/dist/web/git-compare-service.d.ts +45 -0
- package/dist/web/git-compare-service.js +160 -0
- package/dist/web/git-diff-document.d.ts +83 -0
- package/dist/web/git-diff-document.js +214 -0
- package/dist/web/git-file-source.d.ts +25 -0
- package/dist/web/git-file-source.js +21 -0
- package/dist/web/git-history-service.d.ts +151 -0
- package/dist/web/git-history-service.js +610 -0
- package/dist/web/git-index-transaction.d.ts +45 -0
- package/dist/web/git-index-transaction.js +174 -0
- package/dist/web/git-patch-builder.d.ts +30 -0
- package/dist/web/git-patch-builder.js +134 -0
- package/dist/web/git-read-model-service.d.ts +338 -18
- package/dist/web/git-read-model-service.js +1029 -110
- package/dist/web/git-version-service.d.ts +71 -0
- package/dist/web/git-version-service.js +255 -0
- package/dist/web/native-directory-picker.d.ts +63 -0
- package/dist/web/native-directory-picker.js +169 -0
- package/dist/web/protocol.d.ts +51 -2
- package/dist/web/server.js +384 -8
- package/dist/web/workbench-controller.d.ts +126 -10
- package/dist/web/workbench-controller.js +383 -15
- package/dist/web/workspace-open-telemetry.d.ts +55 -0
- package/dist/web/workspace-open-telemetry.js +73 -0
- package/dist/web-client/assets/FilesPanel-BARQCmne.js +1 -0
- package/dist/web-client/assets/GitPanel-DjB9eSiJ.js +4 -0
- package/dist/web-client/assets/ReviewPanel-YhEGlp-G.js +5 -0
- package/dist/web-client/assets/{TerminalPanel-IkCttxgw.js → TerminalPanel-DnWh3Vp2.js} +1 -1
- package/dist/web-client/assets/index--hnGXXjj.css +1 -0
- package/dist/web-client/assets/index-Drja0_BT.js +20 -0
- package/dist/web-client/assets/useAutoNotice-DfQi4G1X.js +1 -0
- package/dist/web-client/favicon.svg +9 -0
- package/dist/web-client/index.html +3 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/web-client/assets/DiffViewer-BrHqudpK.js +0 -5
- package/dist/web-client/assets/FilesPanel-BcFY6L1a.js +0 -1
- package/dist/web-client/assets/GitPanel-B6E5IEXm.js +0 -1
- package/dist/web-client/assets/ResourceSplitLayout-fPYE9JKJ.js +0 -1
- package/dist/web-client/assets/ReviewPanel-5ecTVmhT.js +0 -1
- package/dist/web-client/assets/index-DgVNIqmE.js +0 -20
- package/dist/web-client/assets/index-R6E589Fc.css +0 -1
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GIT_EMPTY_TREE_OID = void 0;
|
|
4
|
+
exports.createGitHistoryReader = createGitHistoryReader;
|
|
5
|
+
/**
|
|
6
|
+
* v0.3.17 S4 — history reading: refs, commit details, commit file lists, search, file history.
|
|
7
|
+
*
|
|
8
|
+
* Split out of the read model service on purpose (plan §8.2 "读模型服务及拆分服务"): this
|
|
9
|
+
* module owns *metadata* about immutable objects, while the service keeps owning the bounded
|
|
10
|
+
* diff stream. Nothing here writes to the working tree or the index, which is what the §10
|
|
11
|
+
* S4 exit evidence asks for ("无工作区写入的历史浏览").
|
|
12
|
+
*
|
|
13
|
+
* Correctness rules this module is responsible for (plan G4):
|
|
14
|
+
* - a normal commit is compared with its parent; a **root** commit is compared with the
|
|
15
|
+
* empty tree instead of failing;
|
|
16
|
+
* - a **merge** commit defaults to its first parent, is explicitly marked as a merge, and
|
|
17
|
+
* lets the caller pick a different parent;
|
|
18
|
+
* - the *commit set* of a two-dot range and the *tree difference* of two commits are
|
|
19
|
+
* different things and are never conflated: the commit list comes from `git log`, the
|
|
20
|
+
* file list from a tree diff;
|
|
21
|
+
* - search runs on the Host across the whole reachable history, never over a rendered page;
|
|
22
|
+
* - a pagination cursor is bound to the frozen tip OID **and** a fingerprint of the query,
|
|
23
|
+
* so a moved ref invalidates it instead of silently returning a different result set.
|
|
24
|
+
*/
|
|
25
|
+
const crypto_1 = require("crypto");
|
|
26
|
+
const redaction_1 = require("../services/redaction");
|
|
27
|
+
const errors_1 = require("./errors");
|
|
28
|
+
/** `git hash-object -t tree /dev/null` — the canonical empty tree, used for root commits. */
|
|
29
|
+
exports.GIT_EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
30
|
+
const REF_LIMIT = 400;
|
|
31
|
+
const COMMIT_FILE_LIMIT = 2000;
|
|
32
|
+
const SEARCH_PAGE_DEFAULT = 30;
|
|
33
|
+
const SEARCH_PAGE_MAX = 100;
|
|
34
|
+
const FILE_HISTORY_LIMIT = 200;
|
|
35
|
+
const RECORD_SEPARATOR = '\x1e';
|
|
36
|
+
const FIELD_SEPARATOR = '\x1f';
|
|
37
|
+
/** Metadata half of the list format; `%b` is fetched separately so a body can never be
|
|
38
|
+
* mistaken for a separator. */
|
|
39
|
+
const LIST_FORMAT = ['%H', '%h', '%ct', '%an', '%s', '%P', '%D'].join(FIELD_SEPARATOR);
|
|
40
|
+
function bounded(value, fallback, min, max, name) {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return fallback;
|
|
43
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
44
|
+
throw new errors_1.WebWorkbenchError(400, `${name} must be an integer from ${min} to ${max}.`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function assertOid(value, name = 'oid') {
|
|
49
|
+
// Full or abbreviated SHA-1/SHA-256 hex. Anything else would be passed to git as a
|
|
50
|
+
// revision expression, which is exactly the injection surface we refuse.
|
|
51
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{4,64}$/iu.test(value)) {
|
|
52
|
+
throw new errors_1.WebWorkbenchError(400, `${name} must be a commit hash.`, 'git_revision_invalid');
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
function assertSafeRelativePath(path) {
|
|
57
|
+
if (typeof path !== 'string' || !path || path.length > 1024) {
|
|
58
|
+
throw new errors_1.WebWorkbenchError(400, 'A file path is required.', 'git_path_invalid');
|
|
59
|
+
}
|
|
60
|
+
if (path.startsWith('/') || path.includes('\0')) {
|
|
61
|
+
throw new errors_1.WebWorkbenchError(400, 'Unsafe Git path.', 'git_path_unsafe');
|
|
62
|
+
}
|
|
63
|
+
const segments = path.split('/');
|
|
64
|
+
if (segments.some(segment => segment === '..' || segment === '.' || segment === '')) {
|
|
65
|
+
throw new errors_1.WebWorkbenchError(400, 'Unsafe Git path.', 'git_path_unsafe');
|
|
66
|
+
}
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
/** Bounded, literal-safe search text. Refused rather than escaped so nothing reaches argv. */
|
|
70
|
+
function assertSearchText(value, name) {
|
|
71
|
+
if (value === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
const trimmed = value.trim();
|
|
74
|
+
if (!trimmed)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (trimmed.length > 200 || /[\0-\x1f\x7f]/u.test(trimmed)) {
|
|
77
|
+
throw new errors_1.WebWorkbenchError(400, `${name} is not a valid search term.`, 'git_query_invalid');
|
|
78
|
+
}
|
|
79
|
+
return trimmed;
|
|
80
|
+
}
|
|
81
|
+
function refKind(name) {
|
|
82
|
+
if (name.startsWith('refs/heads/'))
|
|
83
|
+
return 'branch';
|
|
84
|
+
if (name.startsWith('refs/tags/'))
|
|
85
|
+
return 'tag';
|
|
86
|
+
if (name.startsWith('refs/remotes/'))
|
|
87
|
+
return 'remote';
|
|
88
|
+
return 'other';
|
|
89
|
+
}
|
|
90
|
+
function shortRefName(name) {
|
|
91
|
+
for (const prefix of ['refs/heads/', 'refs/tags/', 'refs/remotes/']) {
|
|
92
|
+
if (name.startsWith(prefix))
|
|
93
|
+
return name.slice(prefix.length);
|
|
94
|
+
}
|
|
95
|
+
return name;
|
|
96
|
+
}
|
|
97
|
+
function parseSummaryRecord(record) {
|
|
98
|
+
const fields = record.replace(/^\s+/u, '').split(FIELD_SEPARATOR);
|
|
99
|
+
if (fields.length < 7)
|
|
100
|
+
return null;
|
|
101
|
+
const [id, shortId, committedSeconds, authorName, subject, parents, decoration] = fields;
|
|
102
|
+
if (!/^[0-9a-f]{7,64}$/iu.test(id))
|
|
103
|
+
return null;
|
|
104
|
+
const parentList = parents
|
|
105
|
+
.split(' ')
|
|
106
|
+
.map(value => value.trim())
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
id,
|
|
110
|
+
shortId,
|
|
111
|
+
subject,
|
|
112
|
+
authorName,
|
|
113
|
+
// `%ct` is seconds; the protocol exposes ISO so the client never re-implements time.
|
|
114
|
+
authoredAt: new Date(Number(committedSeconds) * 1000).toISOString(),
|
|
115
|
+
parents: Object.freeze(parentList),
|
|
116
|
+
isMerge: parentList.length > 1,
|
|
117
|
+
decoration: Object.freeze(decoration
|
|
118
|
+
.split(',')
|
|
119
|
+
.map(value => value.trim())
|
|
120
|
+
.filter(Boolean)),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function parseSummaryPage(raw) {
|
|
124
|
+
return raw
|
|
125
|
+
.split(RECORD_SEPARATOR)
|
|
126
|
+
.map(record => record.replace(/\s+$/u, ''))
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map(parseSummaryRecord)
|
|
129
|
+
.filter((item) => item !== null);
|
|
130
|
+
}
|
|
131
|
+
/** One `--numstat -z` record: `added\tdeleted\t path`, or a rename with an empty path field
|
|
132
|
+
* followed by the old and new names as their own NUL-terminated fields. */
|
|
133
|
+
function parseNumstat(raw) {
|
|
134
|
+
const fields = raw.split('\0');
|
|
135
|
+
const files = [];
|
|
136
|
+
let index = 0;
|
|
137
|
+
while (index < fields.length) {
|
|
138
|
+
const record = fields[index];
|
|
139
|
+
index += 1;
|
|
140
|
+
if (!record)
|
|
141
|
+
continue;
|
|
142
|
+
const [added, deleted, inlinePath] = record.split('\t');
|
|
143
|
+
let path = inlinePath ?? '';
|
|
144
|
+
let renamedFrom = null;
|
|
145
|
+
if (path === '' && index + 1 < fields.length) {
|
|
146
|
+
// Rename: the following two fields are the old and the new path.
|
|
147
|
+
renamedFrom = fields[index];
|
|
148
|
+
path = fields[index + 1];
|
|
149
|
+
index += 2;
|
|
150
|
+
}
|
|
151
|
+
if (!path)
|
|
152
|
+
continue;
|
|
153
|
+
const binary = added === '-' || deleted === '-';
|
|
154
|
+
files.push(Object.freeze({
|
|
155
|
+
path,
|
|
156
|
+
renamedFrom: renamedFrom || null,
|
|
157
|
+
additions: binary ? 0 : Number(added) || 0,
|
|
158
|
+
deletions: binary ? 0 : Number(deleted) || 0,
|
|
159
|
+
binary,
|
|
160
|
+
sensitive: (0, redaction_1.isSensitiveFilePath)(path),
|
|
161
|
+
}));
|
|
162
|
+
if (files.length >= COMMIT_FILE_LIMIT)
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
return files;
|
|
166
|
+
}
|
|
167
|
+
function queryFingerprint(query) {
|
|
168
|
+
return (0, crypto_1.createHash)('sha256')
|
|
169
|
+
.update(JSON.stringify({
|
|
170
|
+
message: query.message ?? null,
|
|
171
|
+
author: query.author ?? null,
|
|
172
|
+
sha: query.sha ?? null,
|
|
173
|
+
path: query.path ?? null,
|
|
174
|
+
since: query.since ?? null,
|
|
175
|
+
until: query.until ?? null,
|
|
176
|
+
rev: query.rev ?? null,
|
|
177
|
+
}))
|
|
178
|
+
.digest('hex')
|
|
179
|
+
.slice(0, 16);
|
|
180
|
+
}
|
|
181
|
+
function createGitHistoryReader(options) {
|
|
182
|
+
const { runGit, requireRoot, revision } = options;
|
|
183
|
+
/** Resolves a commit to a base tree SHA and a human label for the comparison. */
|
|
184
|
+
const resolveBase = async (oid, parentIndex) => {
|
|
185
|
+
const parentRaw = (await runGit(['rev-list', '--parents', '-n', '1', oid])).trim();
|
|
186
|
+
const parents = parentRaw.split(' ').slice(1).filter(Boolean);
|
|
187
|
+
if (parents.length === 0) {
|
|
188
|
+
// A root commit has no parent, so the honest comparison is against the empty tree.
|
|
189
|
+
return { baseOid: exports.GIT_EMPTY_TREE_OID, baseLabel: '空树(根提交)' };
|
|
190
|
+
}
|
|
191
|
+
const chosen = parents[Math.min(parentIndex, parents.length - 1)];
|
|
192
|
+
return {
|
|
193
|
+
baseOid: chosen,
|
|
194
|
+
baseLabel: parents.length > 1
|
|
195
|
+
? `父提交 ${parentIndex + 1}/${parents.length}`
|
|
196
|
+
: '父提交',
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
async refs() {
|
|
201
|
+
await requireRoot();
|
|
202
|
+
const head = (await runGit(['rev-parse', '--verify', '--quiet', 'HEAD']).catch(() => ''))
|
|
203
|
+
.toString()
|
|
204
|
+
.trim();
|
|
205
|
+
// Detached is defined by HEAD being a raw oid rather than a symbolic ref, NOT by
|
|
206
|
+
// "no ref points at this oid". Detaching onto a commit that a branch also points at is
|
|
207
|
+
// the common case, and the earlier oid-comparison heuristic misreported it as attached.
|
|
208
|
+
const symbolicHead = (await runGit(['symbolic-ref', '--quiet', 'HEAD']).catch(() => ''))
|
|
209
|
+
.toString()
|
|
210
|
+
.trim();
|
|
211
|
+
// `for-each-ref` does NOT understand the `%x1f` hex escapes that `git log --format`
|
|
212
|
+
// supports — it prints them literally, which silently produced an empty ref list until
|
|
213
|
+
// this was caught against real Git. A space is the safe separator here because
|
|
214
|
+
// `git check-ref-format` forbids spaces in ref names, and records are newline-delimited.
|
|
215
|
+
const raw = await runGit([
|
|
216
|
+
'for-each-ref',
|
|
217
|
+
`--count=${REF_LIMIT + 1}`,
|
|
218
|
+
'--format=%(refname) %(objectname)',
|
|
219
|
+
'refs/heads',
|
|
220
|
+
'refs/tags',
|
|
221
|
+
'refs/remotes',
|
|
222
|
+
]);
|
|
223
|
+
// An explicit loop rather than a map/filter chain: the intermediate shapes (including
|
|
224
|
+
// the "skip this record" case) stay visible and typed.
|
|
225
|
+
const collected = [];
|
|
226
|
+
for (const record of raw.split('\n')) {
|
|
227
|
+
const trimmed = record.trim();
|
|
228
|
+
if (!trimmed)
|
|
229
|
+
continue;
|
|
230
|
+
const separator = trimmed.indexOf(' ');
|
|
231
|
+
if (separator <= 0)
|
|
232
|
+
continue;
|
|
233
|
+
const name = trimmed.slice(0, separator);
|
|
234
|
+
const oid = trimmed.slice(separator + 1).trim();
|
|
235
|
+
collected.push(Object.freeze({
|
|
236
|
+
name,
|
|
237
|
+
shortName: shortRefName(name),
|
|
238
|
+
kind: refKind(name),
|
|
239
|
+
oid,
|
|
240
|
+
isHead: Boolean(symbolicHead) && name === symbolicHead,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
collected.sort((left, right) => {
|
|
244
|
+
const order = ['branch', 'tag', 'remote', 'other'];
|
|
245
|
+
const byKind = order.indexOf(left.kind) - order.indexOf(right.kind);
|
|
246
|
+
// Stable inside a kind so the list does not reshuffle between reads.
|
|
247
|
+
return byKind !== 0 ? byKind : left.shortName.localeCompare(right.shortName);
|
|
248
|
+
});
|
|
249
|
+
return Object.freeze({
|
|
250
|
+
refs: Object.freeze(collected.slice(0, REF_LIMIT)),
|
|
251
|
+
head: head || null,
|
|
252
|
+
detached: Boolean(head) && !symbolicHead,
|
|
253
|
+
truncated: collected.length > REF_LIMIT,
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
async commit(oid, parentIndex = 0) {
|
|
257
|
+
const revisionOid = assertOid(oid);
|
|
258
|
+
await requireRoot();
|
|
259
|
+
if (!Number.isInteger(parentIndex) || parentIndex < 0 || parentIndex > 63) {
|
|
260
|
+
throw new errors_1.WebWorkbenchError(400, 'parentIndex is out of range.', 'git_query_invalid');
|
|
261
|
+
}
|
|
262
|
+
const summaryRaw = await runGit([
|
|
263
|
+
'log',
|
|
264
|
+
'-1',
|
|
265
|
+
`--format=${LIST_FORMAT}${RECORD_SEPARATOR}`,
|
|
266
|
+
revisionOid,
|
|
267
|
+
'--',
|
|
268
|
+
]);
|
|
269
|
+
const [summary] = parseSummaryPage(summaryRaw);
|
|
270
|
+
if (!summary) {
|
|
271
|
+
throw new errors_1.WebWorkbenchError(404, 'Commit was not found.', 'git_revision_not_found');
|
|
272
|
+
}
|
|
273
|
+
if (parentIndex >= Math.max(1, summary.parents.length)) {
|
|
274
|
+
throw new errors_1.WebWorkbenchError(400, 'parentIndex exceeds the number of parents.', 'git_query_invalid');
|
|
275
|
+
}
|
|
276
|
+
// The body is a separate call: a commit message may contain any byte, including the
|
|
277
|
+
// separators the list format relies on.
|
|
278
|
+
const message = await runGit(['log', '-1', '--format=%B', summary.id]);
|
|
279
|
+
const committerRaw = (await runGit(['log', '-1', `--format=%cn${FIELD_SEPARATOR}%ce${FIELD_SEPARATOR}%cI`, summary.id])).trim();
|
|
280
|
+
const [committerName, authorEmail, committedAt] = committerRaw.split(FIELD_SEPARATOR);
|
|
281
|
+
const base = await resolveBase(summary.id, parentIndex);
|
|
282
|
+
return Object.freeze({
|
|
283
|
+
...summary,
|
|
284
|
+
message: message.replace(/\s+$/u, ''),
|
|
285
|
+
committerName: committerName ?? '',
|
|
286
|
+
authorEmail: authorEmail ?? '',
|
|
287
|
+
committedAt: committedAt ?? summary.authoredAt,
|
|
288
|
+
baseOid: base.baseOid,
|
|
289
|
+
baseLabel: base.baseLabel,
|
|
290
|
+
parentIndex,
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
async commitFiles(oid, parentIndex = 0) {
|
|
294
|
+
const detail = await this.commit(oid, parentIndex);
|
|
295
|
+
const raw = await runGit([
|
|
296
|
+
'diff',
|
|
297
|
+
'--numstat',
|
|
298
|
+
'-z',
|
|
299
|
+
'--no-ext-diff',
|
|
300
|
+
'--no-textconv',
|
|
301
|
+
detail.baseOid,
|
|
302
|
+
detail.id,
|
|
303
|
+
]);
|
|
304
|
+
const files = parseNumstat(raw);
|
|
305
|
+
let additions = 0;
|
|
306
|
+
let deletions = 0;
|
|
307
|
+
for (const file of files) {
|
|
308
|
+
additions += file.additions;
|
|
309
|
+
deletions += file.deletions;
|
|
310
|
+
}
|
|
311
|
+
return Object.freeze({
|
|
312
|
+
commitSha: detail.id,
|
|
313
|
+
baseOid: detail.baseOid,
|
|
314
|
+
baseLabel: detail.baseLabel,
|
|
315
|
+
files: Object.freeze(files),
|
|
316
|
+
additions,
|
|
317
|
+
deletions,
|
|
318
|
+
truncated: files.length >= COMMIT_FILE_LIMIT,
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
async history(query) {
|
|
322
|
+
const root = await requireRoot();
|
|
323
|
+
void root;
|
|
324
|
+
const pageSize = bounded(query.pageSize, SEARCH_PAGE_DEFAULT, 1, SEARCH_PAGE_MAX, 'pageSize');
|
|
325
|
+
const message = assertSearchText(query.message, 'message');
|
|
326
|
+
const author = assertSearchText(query.author, 'author');
|
|
327
|
+
const sha = query.sha ? assertOid(query.sha, 'sha') : undefined;
|
|
328
|
+
const path = query.path ? assertSafeRelativePath(query.path) : undefined;
|
|
329
|
+
const since = assertSearchText(query.since, 'since');
|
|
330
|
+
const until = assertSearchText(query.until, 'until');
|
|
331
|
+
const rev = query.rev ? assertSearchText(query.rev, 'rev') : undefined;
|
|
332
|
+
const tip = (await runGit(['rev-parse', '--verify', '--quiet', rev ?? 'HEAD']).catch(() => ''))
|
|
333
|
+
.toString()
|
|
334
|
+
.trim();
|
|
335
|
+
if (!tip) {
|
|
336
|
+
// A repository without commits is an empty history, not an error (plan G4).
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
items: Object.freeze([]),
|
|
339
|
+
nextCursor: null,
|
|
340
|
+
tipOid: null,
|
|
341
|
+
truncated: false,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const normalized = {
|
|
345
|
+
...(message ? { message } : {}),
|
|
346
|
+
...(author ? { author } : {}),
|
|
347
|
+
...(sha ? { sha } : {}),
|
|
348
|
+
...(path ? { path } : {}),
|
|
349
|
+
...(since ? { since } : {}),
|
|
350
|
+
...(until ? { until } : {}),
|
|
351
|
+
...(rev ? { rev } : {}),
|
|
352
|
+
};
|
|
353
|
+
const scopeRevision = await revision();
|
|
354
|
+
const fingerprint = queryFingerprint(normalized);
|
|
355
|
+
// A SHA is a direct lookup, not a text filter: `git log` cannot filter by object id, and
|
|
356
|
+
// faking it with a range expression would silently conflate "the commit set of a range"
|
|
357
|
+
// with "one commit" (plan G4). So a SHA query resolves the object, checks it is reachable
|
|
358
|
+
// from the current rev, and answers with zero or one item. The other text filters are
|
|
359
|
+
// deliberately NOT combined with it, and the API documents that.
|
|
360
|
+
if (sha) {
|
|
361
|
+
const resolved = (await runGit(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]).catch(() => ''))
|
|
362
|
+
.toString()
|
|
363
|
+
.trim();
|
|
364
|
+
if (!resolved) {
|
|
365
|
+
return Object.freeze({
|
|
366
|
+
items: Object.freeze([]),
|
|
367
|
+
nextCursor: null,
|
|
368
|
+
tipOid: tip,
|
|
369
|
+
truncated: false,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const reachable = await runGit(['merge-base', '--is-ancestor', resolved, tip])
|
|
373
|
+
.then(() => true)
|
|
374
|
+
// `--is-ancestor` exits 1 for "not an ancestor", which is a normal answer.
|
|
375
|
+
.catch(() => false);
|
|
376
|
+
if (!reachable) {
|
|
377
|
+
return Object.freeze({
|
|
378
|
+
items: Object.freeze([]),
|
|
379
|
+
nextCursor: null,
|
|
380
|
+
tipOid: tip,
|
|
381
|
+
truncated: false,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const single = parseSummaryPage(await runGit(['log', '-1', `--format=${LIST_FORMAT}${RECORD_SEPARATOR}`, resolved, '--']));
|
|
385
|
+
return Object.freeze({
|
|
386
|
+
items: Object.freeze(single.slice(0, 1)),
|
|
387
|
+
nextCursor: null,
|
|
388
|
+
tipOid: tip,
|
|
389
|
+
truncated: false,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const offset = query.cursor
|
|
393
|
+
? decodeSearchCursor(query.cursor, { tipOid: tip, revision: scopeRevision, fingerprint })
|
|
394
|
+
: 0;
|
|
395
|
+
const args = [
|
|
396
|
+
'log',
|
|
397
|
+
`--skip=${offset}`,
|
|
398
|
+
`--max-count=${pageSize + 1}`,
|
|
399
|
+
`--format=${LIST_FORMAT}${RECORD_SEPARATOR}`,
|
|
400
|
+
];
|
|
401
|
+
if (message)
|
|
402
|
+
args.push(`--grep=${message}`, '--regexp-ignore-case');
|
|
403
|
+
if (author)
|
|
404
|
+
args.push(`--author=${author}`, '--regexp-ignore-case');
|
|
405
|
+
if (since)
|
|
406
|
+
args.push(`--since=${since}`);
|
|
407
|
+
if (until)
|
|
408
|
+
args.push(`--until=${until}`);
|
|
409
|
+
// `--full-history` so a path filter does not silently drop merges and simplified side
|
|
410
|
+
// branches; the tree difference is reported separately from the commit set (plan G4).
|
|
411
|
+
if (path)
|
|
412
|
+
args.push('--full-history');
|
|
413
|
+
args.push(tip);
|
|
414
|
+
if (path)
|
|
415
|
+
args.push('--', path);
|
|
416
|
+
const raw = await runGit(args);
|
|
417
|
+
const all = parseSummaryPage(raw);
|
|
418
|
+
const hasMore = all.length > pageSize;
|
|
419
|
+
const items = all.slice(0, pageSize);
|
|
420
|
+
return Object.freeze({
|
|
421
|
+
items: Object.freeze(items),
|
|
422
|
+
nextCursor: hasMore
|
|
423
|
+
? encodeSearchCursor({
|
|
424
|
+
tipOid: tip,
|
|
425
|
+
revision: scopeRevision,
|
|
426
|
+
fingerprint,
|
|
427
|
+
offset: offset + items.length,
|
|
428
|
+
})
|
|
429
|
+
: null,
|
|
430
|
+
tipOid: tip,
|
|
431
|
+
truncated: hasMore,
|
|
432
|
+
});
|
|
433
|
+
},
|
|
434
|
+
async graph(input) {
|
|
435
|
+
const limit = bounded(input.limit, 200, 1, 2000, 'limit');
|
|
436
|
+
await requireRoot();
|
|
437
|
+
// The graph columns are taken verbatim from git rather than recomputed: a hand-rolled
|
|
438
|
+
// renderer would be a second implementation of lane assignment, and it would disagree
|
|
439
|
+
// with `git log --graph` in exactly the merge cases that matter.
|
|
440
|
+
const raw = await runGit([
|
|
441
|
+
'log',
|
|
442
|
+
'--graph',
|
|
443
|
+
`--max-count=${limit + 1}`,
|
|
444
|
+
'--date-order',
|
|
445
|
+
'--format=%x1f%H%x1f%h%x1f%s',
|
|
446
|
+
]).catch(() => '');
|
|
447
|
+
const rows = [];
|
|
448
|
+
for (const line of raw.split('\n')) {
|
|
449
|
+
if (!line.trim())
|
|
450
|
+
continue;
|
|
451
|
+
const separator = line.indexOf('\x1f');
|
|
452
|
+
if (separator < 0) {
|
|
453
|
+
rows.push(Object.freeze({ graph: line, id: null, shortId: null, subject: '' }));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const graph = line.slice(0, separator);
|
|
457
|
+
const fields = line.slice(separator + 1).split('\x1f');
|
|
458
|
+
rows.push(Object.freeze({
|
|
459
|
+
graph,
|
|
460
|
+
id: fields[0] || null,
|
|
461
|
+
shortId: fields[1] || null,
|
|
462
|
+
subject: fields[2] ?? '',
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
const truncated = rows.length > limit;
|
|
466
|
+
return Object.freeze({ rows: Object.freeze(rows.slice(0, limit)), truncated });
|
|
467
|
+
},
|
|
468
|
+
async blame(input) {
|
|
469
|
+
const path = assertSafeRelativePath(input.path);
|
|
470
|
+
const limit = bounded(input.limit, 2000, 1, 5000, 'limit');
|
|
471
|
+
await requireRoot();
|
|
472
|
+
const rev = input.rev ? assertSearchText(input.rev, 'rev') : undefined;
|
|
473
|
+
const target = rev ?? null;
|
|
474
|
+
const tracked = (await runGit(['rev-parse', '--verify', '--quiet', rev ?? 'HEAD']).catch(() => ''))
|
|
475
|
+
.toString()
|
|
476
|
+
.trim();
|
|
477
|
+
if (!tracked) {
|
|
478
|
+
return Object.freeze({ path, rev: rev ?? null, lines: Object.freeze([]), truncated: false });
|
|
479
|
+
}
|
|
480
|
+
// `--porcelain` is machine-readable and stable; the date stays ISO so the client never
|
|
481
|
+
// re-implements time. Uncommitted lines show as boundary/not-committed metadata.
|
|
482
|
+
const raw = await runGit([
|
|
483
|
+
'blame',
|
|
484
|
+
'--porcelain',
|
|
485
|
+
...(target ? [target] : []),
|
|
486
|
+
'--',
|
|
487
|
+
path,
|
|
488
|
+
]);
|
|
489
|
+
const commits = new Map();
|
|
490
|
+
const lines = [];
|
|
491
|
+
let currentOid = null;
|
|
492
|
+
let boundary = false;
|
|
493
|
+
let pendingAuthor = '';
|
|
494
|
+
let pendingTime = '';
|
|
495
|
+
let pendingSummary = '';
|
|
496
|
+
let lineNumber = 0;
|
|
497
|
+
for (const line of raw.split('\n')) {
|
|
498
|
+
if (line.startsWith('\t')) {
|
|
499
|
+
if (!currentOid)
|
|
500
|
+
continue;
|
|
501
|
+
lineNumber += 1;
|
|
502
|
+
if (lineNumber > limit)
|
|
503
|
+
break;
|
|
504
|
+
const known = commits.get(currentOid);
|
|
505
|
+
lines.push(Object.freeze({
|
|
506
|
+
lineNumber,
|
|
507
|
+
commitShort: currentOid.slice(0, 10),
|
|
508
|
+
author: known?.author ?? pendingAuthor,
|
|
509
|
+
authoredAt: known?.authoredAt ?? pendingTime,
|
|
510
|
+
summary: known?.summary ?? pendingSummary,
|
|
511
|
+
isBoundary: boundary,
|
|
512
|
+
}));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const header = /^([0-9a-f]{40}) \d+ \d+(?: \d+)?$/.exec(line);
|
|
516
|
+
if (header) {
|
|
517
|
+
currentOid = header[1];
|
|
518
|
+
boundary = false;
|
|
519
|
+
pendingAuthor = '';
|
|
520
|
+
pendingTime = '';
|
|
521
|
+
pendingSummary = '';
|
|
522
|
+
const cached = commits.get(currentOid);
|
|
523
|
+
if (cached) {
|
|
524
|
+
pendingAuthor = cached.author;
|
|
525
|
+
pendingTime = cached.authoredAt;
|
|
526
|
+
pendingSummary = cached.summary;
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (!currentOid)
|
|
531
|
+
continue;
|
|
532
|
+
const separator = line.indexOf(' ');
|
|
533
|
+
const key = separator < 0 ? line : line.slice(0, separator);
|
|
534
|
+
const value = separator < 0 ? '' : line.slice(separator + 1);
|
|
535
|
+
if (key === 'author')
|
|
536
|
+
pendingAuthor = value;
|
|
537
|
+
else if (key === 'author-time')
|
|
538
|
+
pendingTime = new Date(Number(value) * 1000).toISOString();
|
|
539
|
+
else if (key === 'summary')
|
|
540
|
+
pendingSummary = value;
|
|
541
|
+
else if (key === 'boundary')
|
|
542
|
+
boundary = true;
|
|
543
|
+
if (pendingAuthor && pendingTime && pendingSummary && !commits.has(currentOid)) {
|
|
544
|
+
commits.set(currentOid, Object.freeze({ author: pendingAuthor, authoredAt: pendingTime, summary: pendingSummary }));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return Object.freeze({
|
|
548
|
+
path,
|
|
549
|
+
rev: rev ?? null,
|
|
550
|
+
lines: Object.freeze(lines),
|
|
551
|
+
truncated: lineNumber > limit,
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
async fileHistory(input) {
|
|
555
|
+
const path = assertSafeRelativePath(input.path);
|
|
556
|
+
const limit = bounded(input.limit, SEARCH_PAGE_DEFAULT, 1, FILE_HISTORY_LIMIT, 'limit');
|
|
557
|
+
await requireRoot();
|
|
558
|
+
const rev = input.rev ? assertSearchText(input.rev, 'rev') : undefined;
|
|
559
|
+
const target = rev ?? 'HEAD';
|
|
560
|
+
const tracked = (await runGit(['rev-parse', '--verify', '--quiet', target]).catch(() => ''))
|
|
561
|
+
.toString()
|
|
562
|
+
.trim();
|
|
563
|
+
if (!tracked) {
|
|
564
|
+
return Object.freeze({ path, items: Object.freeze([]), followed: false, truncated: false });
|
|
565
|
+
}
|
|
566
|
+
// `--follow` only accepts a single path, which is exactly this case. It explains its own
|
|
567
|
+
// limits for copies and complex merges (plan G6) — we surface `followed` rather than
|
|
568
|
+
// pretending tracking is complete.
|
|
569
|
+
const raw = await runGit([
|
|
570
|
+
'log',
|
|
571
|
+
`--max-count=${limit + 1}`,
|
|
572
|
+
`--format=${LIST_FORMAT}${RECORD_SEPARATOR}`,
|
|
573
|
+
'--follow',
|
|
574
|
+
'--',
|
|
575
|
+
path,
|
|
576
|
+
]);
|
|
577
|
+
const all = parseSummaryPage(raw);
|
|
578
|
+
const items = all.slice(0, limit);
|
|
579
|
+
return Object.freeze({
|
|
580
|
+
path,
|
|
581
|
+
items: Object.freeze(items),
|
|
582
|
+
followed: true,
|
|
583
|
+
truncated: all.length > limit,
|
|
584
|
+
});
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function encodeSearchCursor(scope) {
|
|
589
|
+
return Buffer.from(JSON.stringify({ v: 1, ...scope }), 'utf8').toString('base64url');
|
|
590
|
+
}
|
|
591
|
+
function decodeSearchCursor(cursor, expected) {
|
|
592
|
+
let parsed;
|
|
593
|
+
try {
|
|
594
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
throw new errors_1.WebWorkbenchError(400, 'History cursor is not valid.', 'git_cursor_invalid');
|
|
598
|
+
}
|
|
599
|
+
const candidate = parsed;
|
|
600
|
+
if (candidate.tipOid !== expected.tipOid ||
|
|
601
|
+
candidate.revision !== expected.revision ||
|
|
602
|
+
candidate.fingerprint !== expected.fingerprint ||
|
|
603
|
+
typeof candidate.offset !== 'number' ||
|
|
604
|
+
!Number.isInteger(candidate.offset) ||
|
|
605
|
+
candidate.offset < 0) {
|
|
606
|
+
throw new errors_1.WebWorkbenchError(409, 'History changed; reload the list before paginating.', 'git_cursor_invalid');
|
|
607
|
+
}
|
|
608
|
+
return candidate.offset;
|
|
609
|
+
}
|
|
610
|
+
//# sourceMappingURL=git-history-service.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface GitIndexPaths {
|
|
2
|
+
readonly root: string;
|
|
3
|
+
/** `git rev-parse --absolute-git-dir`. */
|
|
4
|
+
readonly gitDir: string;
|
|
5
|
+
/** `git rev-parse --git-path index`, resolved to an absolute path. */
|
|
6
|
+
readonly indexPath: string;
|
|
7
|
+
}
|
|
8
|
+
export interface IndexTransactionContext {
|
|
9
|
+
/** Value for `GIT_INDEX_FILE`; the mutation must target this index, not the real one. */
|
|
10
|
+
readonly indexFile: string;
|
|
11
|
+
readonly env: NodeJS.ProcessEnv;
|
|
12
|
+
/** Index digest before the transaction, for post-hoc comparison. */
|
|
13
|
+
readonly before: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Environment for index writes.
|
|
17
|
+
*
|
|
18
|
+
* `GIT_CONFIG_GLOBAL` is intentionally *not* pointed at /dev/null here, unlike the read
|
|
19
|
+
* path: staging must resolve the user's `core.autocrlf`, filters and `.gitattributes`
|
|
20
|
+
* exactly as their own Git would, otherwise we would silently stage different bytes than
|
|
21
|
+
* they expect (plan §7.8). Hooks stay disabled — `add`/`apply` must not run user hooks.
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeGitEnvironment(overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
24
|
+
/**
|
|
25
|
+
* `-c` prefix for index writes. Hooks are disabled because none of the index mutations run
|
|
26
|
+
* hooks; the commit runner uses its own prefix and does not reuse this one.
|
|
27
|
+
*/
|
|
28
|
+
export declare function writeGitPrefix(): string[];
|
|
29
|
+
export declare function indexLockPath(indexPath: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Runs `work` against a private copy of the index and publishes it atomically.
|
|
32
|
+
*
|
|
33
|
+
* `verify` receives the temp index path so the caller can assert the mutation actually did
|
|
34
|
+
* what it intended (for example that the target path is now staged) before publication.
|
|
35
|
+
*/
|
|
36
|
+
export declare function withIndexTransaction<T>(paths: GitIndexPaths, seed: (context: {
|
|
37
|
+
readonly indexFile: string;
|
|
38
|
+
readonly env: NodeJS.ProcessEnv;
|
|
39
|
+
}) => Promise<void>, work: (context: IndexTransactionContext) => Promise<T>, verify?: (result: T, context: IndexTransactionContext) => Promise<void>): Promise<T>;
|
|
40
|
+
/**
|
|
41
|
+
* Failure text Git prints when it refuses because the index changed under it. Surfaced so
|
|
42
|
+
* callers can report an honest conflict instead of a generic error.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isIndexConflictOutput(text: string): boolean;
|
|
45
|
+
//# sourceMappingURL=git-index-transaction.d.ts.map
|