@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/README.md +6 -8
  3. package/README.zh-CN.md +4 -5
  4. package/dist/runtime/release-receipts.d.ts +15 -3
  5. package/dist/runtime/release-receipts.js +27 -1
  6. package/dist/services/session-storage.d.ts +9 -0
  7. package/dist/services/session-storage.js +27 -1
  8. package/dist/web/git-compare-service.d.ts +45 -0
  9. package/dist/web/git-compare-service.js +160 -0
  10. package/dist/web/git-diff-document.d.ts +83 -0
  11. package/dist/web/git-diff-document.js +214 -0
  12. package/dist/web/git-file-source.d.ts +25 -0
  13. package/dist/web/git-file-source.js +21 -0
  14. package/dist/web/git-history-service.d.ts +151 -0
  15. package/dist/web/git-history-service.js +610 -0
  16. package/dist/web/git-index-transaction.d.ts +45 -0
  17. package/dist/web/git-index-transaction.js +174 -0
  18. package/dist/web/git-patch-builder.d.ts +30 -0
  19. package/dist/web/git-patch-builder.js +134 -0
  20. package/dist/web/git-read-model-service.d.ts +338 -18
  21. package/dist/web/git-read-model-service.js +1029 -110
  22. package/dist/web/git-version-service.d.ts +71 -0
  23. package/dist/web/git-version-service.js +255 -0
  24. package/dist/web/native-directory-picker.d.ts +63 -0
  25. package/dist/web/native-directory-picker.js +169 -0
  26. package/dist/web/protocol.d.ts +51 -2
  27. package/dist/web/server.js +384 -8
  28. package/dist/web/workbench-controller.d.ts +126 -10
  29. package/dist/web/workbench-controller.js +383 -15
  30. package/dist/web/workspace-open-telemetry.d.ts +55 -0
  31. package/dist/web/workspace-open-telemetry.js +73 -0
  32. package/dist/web-client/assets/FilesPanel-BARQCmne.js +1 -0
  33. package/dist/web-client/assets/GitPanel-DjB9eSiJ.js +4 -0
  34. package/dist/web-client/assets/ReviewPanel-YhEGlp-G.js +5 -0
  35. package/dist/web-client/assets/{TerminalPanel-IkCttxgw.js → TerminalPanel-DnWh3Vp2.js} +1 -1
  36. package/dist/web-client/assets/index--hnGXXjj.css +1 -0
  37. package/dist/web-client/assets/index-Drja0_BT.js +20 -0
  38. package/dist/web-client/assets/useAutoNotice-DfQi4G1X.js +1 -0
  39. package/dist/web-client/favicon.svg +9 -0
  40. package/dist/web-client/index.html +3 -2
  41. package/npm-shrinkwrap.json +2 -2
  42. package/package.json +1 -1
  43. package/dist/web-client/assets/DiffViewer-BrHqudpK.js +0 -5
  44. package/dist/web-client/assets/FilesPanel-BcFY6L1a.js +0 -1
  45. package/dist/web-client/assets/GitPanel-B6E5IEXm.js +0 -1
  46. package/dist/web-client/assets/ResourceSplitLayout-fPYE9JKJ.js +0 -1
  47. package/dist/web-client/assets/ReviewPanel-5ecTVmhT.js +0 -1
  48. package/dist/web-client/assets/index-DgVNIqmE.js +0 -20
  49. 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