@orion-agents/orion-code 0.3.15 → 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 (44) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/runtime/release-receipts.d.ts +15 -3
  3. package/dist/runtime/release-receipts.js +27 -1
  4. package/dist/services/session-storage.d.ts +9 -0
  5. package/dist/services/session-storage.js +27 -1
  6. package/dist/web/git-compare-service.d.ts +45 -0
  7. package/dist/web/git-compare-service.js +160 -0
  8. package/dist/web/git-diff-document.d.ts +83 -0
  9. package/dist/web/git-diff-document.js +214 -0
  10. package/dist/web/git-file-source.d.ts +25 -0
  11. package/dist/web/git-file-source.js +21 -0
  12. package/dist/web/git-history-service.d.ts +151 -0
  13. package/dist/web/git-history-service.js +610 -0
  14. package/dist/web/git-index-transaction.d.ts +45 -0
  15. package/dist/web/git-index-transaction.js +174 -0
  16. package/dist/web/git-patch-builder.d.ts +30 -0
  17. package/dist/web/git-patch-builder.js +134 -0
  18. package/dist/web/git-read-model-service.d.ts +338 -18
  19. package/dist/web/git-read-model-service.js +1029 -110
  20. package/dist/web/git-version-service.d.ts +71 -0
  21. package/dist/web/git-version-service.js +255 -0
  22. package/dist/web/native-directory-picker.d.ts +63 -0
  23. package/dist/web/native-directory-picker.js +169 -0
  24. package/dist/web/protocol.d.ts +51 -2
  25. package/dist/web/server.js +384 -8
  26. package/dist/web/workbench-controller.d.ts +126 -10
  27. package/dist/web/workbench-controller.js +383 -15
  28. package/dist/web/workspace-open-telemetry.d.ts +55 -0
  29. package/dist/web/workspace-open-telemetry.js +73 -0
  30. package/dist/web-client/assets/FilesPanel-BARQCmne.js +1 -0
  31. package/dist/web-client/assets/GitPanel-DjB9eSiJ.js +4 -0
  32. package/dist/web-client/assets/ReviewPanel-YhEGlp-G.js +5 -0
  33. package/dist/web-client/assets/{TerminalPanel-BpsLphLv.js → TerminalPanel-DnWh3Vp2.js} +1 -1
  34. package/dist/web-client/assets/{index-D3sxrAh_.css → index--hnGXXjj.css} +1 -1
  35. package/dist/web-client/assets/index-Drja0_BT.js +20 -0
  36. package/dist/web-client/assets/{useAutoNotice-BeSf9Dfq.js → useAutoNotice-DfQi4G1X.js} +1 -1
  37. package/dist/web-client/index.html +2 -2
  38. package/npm-shrinkwrap.json +2 -2
  39. package/package.json +1 -1
  40. package/dist/web-client/assets/DiffViewer-C0A42Lma.js +0 -5
  41. package/dist/web-client/assets/FilesPanel-DiXKnFMj.js +0 -1
  42. package/dist/web-client/assets/GitPanel--_Yrbldp.js +0 -1
  43. package/dist/web-client/assets/ReviewPanel-BilzL9Ma.js +0 -1
  44. package/dist/web-client/assets/index-C2M48tcK.js +0 -20
@@ -1,13 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GitReadModelServiceV1 = void 0;
4
+ exports.normalizeCommitMessage = normalizeCommitMessage;
5
+ exports.assertRelativeGitPath = assertRelativeGitPath;
4
6
  const child_process_1 = require("child_process");
5
7
  const crypto_1 = require("crypto");
6
8
  const fs_1 = require("fs");
7
9
  const path_1 = require("path");
8
10
  const redaction_1 = require("../services/redaction");
9
11
  const errors_1 = require("./errors");
12
+ const git_diff_document_1 = require("./git-diff-document");
13
+ const git_patch_builder_1 = require("./git-patch-builder");
14
+ const git_history_service_1 = require("./git-history-service");
15
+ const git_compare_service_1 = require("./git-compare-service");
16
+ const git_version_service_1 = require("./git-version-service");
17
+ const git_file_source_1 = require("./git-file-source");
18
+ const git_index_transaction_1 = require("./git-index-transaction");
10
19
  const GIT_TIMEOUT_MS = 5000;
20
+ /** plan §7.9 — the commit runner gets its own bounded budget for hooks and signing. */
21
+ const GIT_COMMIT_TIMEOUT_MS = 60000;
11
22
  const GIT_MAX_BUFFER = 8 * 1024 * 1024;
12
23
  const DEFAULT_STATUS_PAGE_SIZE = 200;
13
24
  const MAX_STATUS_PAGE_SIZE = 2000;
@@ -20,12 +31,18 @@ const MAX_DIFF_BYTES = 1024 * 1024;
20
31
  const GIT_NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
21
32
  /** Bounded argv-only Git status/log/diff projection for one active Workspace. */
22
33
  class GitReadModelServiceV1 {
23
- constructor(workspace) {
34
+ constructor(workspace, options = {}) {
24
35
  this.secret = (0, crypto_1.randomBytes)(32);
25
- this.pathById = new Map();
26
- this.idByPath = new Map();
36
+ /**
37
+ * v0.3.17 S1 — tokens are keyed by (source, path), not by path alone. A file that is
38
+ * both staged and modified in the worktree is two different selectable objects, so a
39
+ * path-only key silently conflated the two comparison sources.
40
+ */
41
+ this.targetById = new Map();
42
+ this.idByKey = new Map();
27
43
  this.performance = { processCount: 0, bytesRead: 0, itemsParsed: 0 };
28
44
  this.workspace = canonicalDirectory(workspace);
45
+ this.commitTimeoutMs = boundedInteger(options.commitTimeoutMs ?? GIT_COMMIT_TIMEOUT_MS, 1000, 600000, 'commitTimeoutMs');
29
46
  }
30
47
  performanceCounters() {
31
48
  return Object.freeze({ ...this.performance });
@@ -33,16 +50,38 @@ class GitReadModelServiceV1 {
33
50
  async status(input = {}) {
34
51
  const snapshot = await this.capture();
35
52
  const pageSize = boundedInteger(input.pageSize ?? DEFAULT_STATUS_PAGE_SIZE, 1, MAX_STATUS_PAGE_SIZE, 'pageSize');
53
+ const group = normalizeSource(input.group);
54
+ const query = normalizeQuery(input.query);
55
+ // v0.3.17 S1 — the query scope is bound into the cursor, so pages can never be
56
+ // stitched across two different filters.
57
+ const scope = statusScope(group, query);
58
+ const matched = snapshot.records.filter(record => {
59
+ if (group && !recordSources(record).includes(group))
60
+ return false;
61
+ return query === undefined || record.path.toLocaleLowerCase().includes(query);
62
+ });
36
63
  const offset = input.cursor
37
- ? this.decodeCursor(input.cursor, 'status', snapshot.revision).offset
64
+ ? this.decodeCursor(input.cursor, 'status', snapshot.revision, undefined, scope).offset
38
65
  : 0;
39
- const records = snapshot.records.slice(offset, offset + pageSize);
40
- const projected = records.map(record => this.projectFile(record));
41
- const staged = projected.filter(file => file.indexStatus !== ' ' && file.indexStatus !== '?' && !isConflict(file));
42
- const unstaged = projected.filter(file => file.worktreeStatus !== ' ' && file.worktreeStatus !== '?' && !isConflict(file));
43
- const untracked = projected.filter(file => file.indexStatus === '?');
44
- const conflicted = projected.filter(isConflict);
45
- const nextOffset = offset + records.length;
66
+ const page = matched.slice(offset, offset + pageSize);
67
+ const staged = [];
68
+ const unstaged = [];
69
+ const untracked = [];
70
+ const conflicted = [];
71
+ const buckets = {
72
+ staged,
73
+ unstaged,
74
+ untracked,
75
+ conflict: conflicted,
76
+ };
77
+ for (const record of page) {
78
+ for (const source of recordSources(record)) {
79
+ buckets[source].push(this.projectFile(record, source));
80
+ }
81
+ }
82
+ // Totals cover the whole matched scope, never just the loaded page.
83
+ const counts = countSources(matched);
84
+ const nextOffset = offset + page.length;
46
85
  return Object.freeze({
47
86
  isRepository: snapshot.isRepository,
48
87
  repositoryRevision: snapshot.revision,
@@ -58,14 +97,16 @@ class GitReadModelServiceV1 {
58
97
  unstaged: Object.freeze(unstaged),
59
98
  untracked: Object.freeze(untracked),
60
99
  conflicted: Object.freeze(conflicted),
61
- totalFiles: snapshot.records.length,
62
- truncated: nextOffset < snapshot.records.length,
63
- nextCursor: nextOffset < snapshot.records.length
100
+ totalFiles: matched.length,
101
+ counts: Object.freeze({ ...counts, loaded: page.length }),
102
+ truncated: nextOffset < matched.length,
103
+ nextCursor: nextOffset < matched.length
64
104
  ? this.encodeCursor({
65
- version: 1,
105
+ version: 2,
66
106
  kind: 'status',
67
107
  revision: snapshot.revision,
68
108
  offset: nextOffset,
109
+ scope,
69
110
  })
70
111
  : null,
71
112
  });
@@ -102,7 +143,7 @@ class GitReadModelServiceV1 {
102
143
  items: Object.freeze(items),
103
144
  nextCursor: hasMore
104
145
  ? this.encodeCursor({
105
- version: 1,
146
+ version: 2,
106
147
  kind: 'log',
107
148
  revision: snapshot.revision,
108
149
  offset: offset + items.length,
@@ -115,22 +156,30 @@ class GitReadModelServiceV1 {
115
156
  if (!snapshot.isRepository || !snapshot.root) {
116
157
  throw new errors_1.WebWorkbenchError(404, 'Git repository is unavailable.', 'git_not_repository');
117
158
  }
118
- const path = this.pathById.get(input.fileId);
119
- if (!path)
120
- throw new errors_1.WebWorkbenchError(404, 'Git file was not found.', 'git_file_not_found');
159
+ const target = this.resolveTarget(input.fileId);
160
+ const { path } = target;
121
161
  const record = snapshot.records.find(candidate => candidate.path === path);
122
162
  if (!record) {
123
163
  throw new errors_1.WebWorkbenchError(409, 'Git file changed before diff.', 'git_revision_conflict');
124
164
  }
165
+ // A token names one comparison source. If that source no longer exists (the file was
166
+ // staged away, or the worktree edit was reverted), the selection is stale rather than
167
+ // silently rendered against the other side.
168
+ if (!recordStillMatches(record, target.source)) {
169
+ throw new errors_1.WebWorkbenchError(409, 'Git file changed before diff.', 'git_revision_conflict');
170
+ }
125
171
  if (isSensitiveGitRecord(record)) {
126
172
  throw new errors_1.WebWorkbenchError(403, 'Sensitive file content is not available in the Web Workbench.', 'sensitive_file_blocked');
127
173
  }
128
174
  const lineLimit = boundedInteger(input.lineLimit ?? DEFAULT_DIFF_LINES, 1, MAX_DIFF_LINES, 'lineLimit');
129
175
  const byteLimit = boundedInteger(input.byteLimit ?? DEFAULT_DIFF_BYTES, 1024, MAX_DIFF_BYTES, 'byteLimit');
176
+ // Defaulted once so the command, the cursor and the document all describe the same view.
177
+ const ignoreWhitespace = input.ignoreWhitespace === true;
178
+ const wordDiff = input.wordDiff === true;
130
179
  const offset = input.cursor
131
- ? this.decodeCursor(input.cursor, 'diff', snapshot.revision, input.fileId).offset
180
+ ? this.decodeCursor(input.cursor, 'diff', snapshot.revision, input.fileId, diffScope(ignoreWhitespace, wordDiff)).offset
132
181
  : 0;
133
- const commands = diffCommands(record, path);
182
+ const commands = diffCommands(path, target.source, ignoreWhitespace, wordDiff);
134
183
  const page = await streamDiffPage({
135
184
  cwd: snapshot.root,
136
185
  commands,
@@ -157,21 +206,252 @@ class GitReadModelServiceV1 {
157
206
  return Object.freeze({
158
207
  fileId: input.fileId,
159
208
  path: (0, redaction_1.redactTraceText)(path),
209
+ source: target.source,
160
210
  repositoryRevision: snapshot.revision,
161
211
  binary,
162
212
  lines: Object.freeze(lines),
163
213
  nextCursor: page.hasMore
164
214
  ? this.encodeCursor({
165
- version: 1,
215
+ version: 2,
166
216
  kind: 'diff',
167
217
  revision: snapshot.revision,
168
218
  offset: offset + page.returnedLines,
169
219
  fileId: input.fileId,
220
+ scope: diffScope(ignoreWhitespace, wordDiff),
170
221
  })
171
222
  : null,
172
223
  truncated: page.hasMore,
173
224
  });
174
225
  }
226
+ /**
227
+ * v0.3.17 S2 — the structured form of the same comparison. It reuses `diff()` verbatim,
228
+ * so the hardened read path, byte/line budget and pagination stay in one place; only the
229
+ * projection changes.
230
+ */
231
+ async diffDocument(input) {
232
+ const page = await this.diff(input);
233
+ return (0, git_diff_document_1.parseGitDiffDocument)({
234
+ fileToken: page.fileId,
235
+ path: page.path,
236
+ source: page.source,
237
+ repositoryRevision: page.repositoryRevision,
238
+ lines: page.lines,
239
+ hasMore: page.nextCursor !== null,
240
+ nextCursor: page.nextCursor,
241
+ });
242
+ }
243
+ historyReader() {
244
+ this.historyReaderInstance ?? (this.historyReaderInstance = (0, git_history_service_1.createGitHistoryReader)({
245
+ runGit: async (args) => this.runGit(args, await this.requireRepositoryRoot()),
246
+ requireRoot: () => this.requireRepositoryRoot(),
247
+ revision: async () => (await this.capture()).revision,
248
+ }));
249
+ return this.historyReaderInstance;
250
+ }
251
+ compareReader() {
252
+ this.compareReaderInstance ?? (this.compareReaderInstance = (0, git_compare_service_1.createGitCompareReader)({
253
+ runGit: async (args) => this.runGit(args, await this.requireRepositoryRoot()),
254
+ }));
255
+ return this.compareReaderInstance;
256
+ }
257
+ /**
258
+ * v0.3.17 S5 — branch/version comparison. Read-only.
259
+ *
260
+ * The resolved OIDs are part of the result on purpose: the UI freezes the opened result
261
+ * against them, so a ref that moves afterwards produces a "there is an update" hint
262
+ * instead of silently comparing against a different tree.
263
+ */
264
+ async compare(input) {
265
+ return this.compareReader().compare(input);
266
+ }
267
+ /** Resolves a pair of refs without computing a comparison (used to detect ref movement). */
268
+ async resolveComparePair(baseRef, headRef) {
269
+ const pair = await this.compareReader().resolvePair(baseRef, headRef);
270
+ return Object.freeze({ baseOid: pair.baseOid, headOid: pair.headOid });
271
+ }
272
+ /**
273
+ * v0.3.17 S5 — the diff one file shows between two arbitrary commits.
274
+ *
275
+ * Same bounded stream as every other diff; the only new thing is that the base is a
276
+ * caller-supplied resolved OID instead of a parent. The cursor margin binds the page to
277
+ * this exact base/head/path triple, so a different comparison cannot continue it.
278
+ */
279
+ async compareFileDiffDocument(input) {
280
+ const root = await this.requireRepositoryRoot();
281
+ const path = assertRelativeGitPath(input.path);
282
+ if ((0, redaction_1.isSensitiveFilePath)(path)) {
283
+ throw new errors_1.WebWorkbenchError(403, 'Sensitive file content is not available in the Web Workbench.', 'sensitive_file_blocked');
284
+ }
285
+ for (const oid of [input.baseOid, input.headOid]) {
286
+ if (!/^[0-9a-f]{7,64}$/iu.test(oid)) {
287
+ throw new errors_1.WebWorkbenchError(400, 'A commit hash is required.', 'git_revision_invalid');
288
+ }
289
+ }
290
+ const snapshot = await this.capture();
291
+ const lineLimit = boundedInteger(input.lineLimit ?? DEFAULT_DIFF_LINES, 1, MAX_DIFF_LINES, 'lineLimit');
292
+ const byteLimit = boundedInteger(input.byteLimit ?? DEFAULT_DIFF_BYTES, 1024, MAX_DIFF_BYTES, 'byteLimit');
293
+ const margin = (0, crypto_1.createHash)('sha256')
294
+ .update(`compare-diff:${input.baseOid}:${input.headOid}:${path}:${snapshot.revision}`)
295
+ .digest('hex')
296
+ .slice(0, 24);
297
+ const offset = input.cursor ? decodeCommitDiffCursor(input.cursor, margin) : 0;
298
+ const page = await streamDiffPage({
299
+ cwd: root,
300
+ commands: Object.freeze([
301
+ {
302
+ title: 'Compare',
303
+ args: [
304
+ 'diff',
305
+ '--no-ext-diff',
306
+ '--no-textconv',
307
+ '--unified=3',
308
+ input.baseOid,
309
+ input.headOid,
310
+ '--',
311
+ path,
312
+ ],
313
+ },
314
+ ]),
315
+ offset,
316
+ lineLimit,
317
+ byteLimit,
318
+ onProcess: () => {
319
+ this.performance.processCount += 1;
320
+ },
321
+ onBytes: bytes => {
322
+ this.performance.bytesRead += bytes;
323
+ },
324
+ });
325
+ this.performance.itemsParsed += page.linesParsed;
326
+ const document = (0, git_diff_document_1.parseGitDiffDocument)({
327
+ fileToken: `compare_${margin}`,
328
+ path,
329
+ source: 'commit',
330
+ repositoryRevision: snapshot.revision,
331
+ lines: page.lines,
332
+ hasMore: page.hasMore,
333
+ nextCursor: page.hasMore
334
+ ? encodeCommitDiffCursor({ margin, offset: offset + page.returnedLines })
335
+ : null,
336
+ });
337
+ return Object.freeze({
338
+ ...document,
339
+ baseOid: input.baseOid,
340
+ baseLabel: `比较 ${input.baseOid.slice(0, 10)} → ${input.headOid.slice(0, 10)}`,
341
+ });
342
+ }
343
+ versionReader() {
344
+ this.versionReaderInstance ?? (this.versionReaderInstance = (0, git_version_service_1.createGitVersionReader)({
345
+ runGit: async (args) => this.runGit(args, await this.requireRepositoryRoot()),
346
+ runGitBytes: async (args) => this.runGitBytes(args, await this.requireRepositoryRoot()),
347
+ }));
348
+ return this.versionReaderInstance;
349
+ }
350
+ /**
351
+ * v0.3.17 S5 — base / ours / theirs / worktree for a conflicted file.
352
+ *
353
+ * Read from index stages 1/2/3 rather than assembled from two diffs: a modify/delete
354
+ * conflict genuinely has no base, and pretending otherwise is what plan G6 forbids.
355
+ */
356
+ async conflictVersions(path) {
357
+ return this.versionReader().conflict(path);
358
+ }
359
+ /** Reads one file at a revision — including a file that no longer exists locally. */
360
+ async fileBlob(input) {
361
+ return this.versionReader().blob(input);
362
+ }
363
+ /** Recorded gitlink SHAs only; submodules are never traversed (plan G6). */
364
+ async submodules() {
365
+ return this.versionReader().submodules();
366
+ }
367
+ async refs() {
368
+ return this.historyReader().refs();
369
+ }
370
+ async history(query) {
371
+ return this.historyReader().history(query);
372
+ }
373
+ async commitDetail(oid, parentIndex = 0) {
374
+ return this.historyReader().commit(oid, parentIndex);
375
+ }
376
+ async commitFiles(oid, parentIndex = 0) {
377
+ return this.historyReader().commitFiles(oid, parentIndex);
378
+ }
379
+ async graph(input = {}) {
380
+ return this.historyReader().graph(input);
381
+ }
382
+ async blame(input) {
383
+ return this.historyReader().blame(input);
384
+ }
385
+ async fileHistory(input) {
386
+ return this.historyReader().fileHistory(input);
387
+ }
388
+ /**
389
+ * v0.3.17 S4 — the diff a commit introduced to one file.
390
+ *
391
+ * Rebuilds the comparison from the commit's real base (first parent, a chosen parent, or the
392
+ * empty tree for a root commit) and reuses `streamDiffPage`, so the byte/line budget,
393
+ * pagination and `--no-ext-diff`/`--no-textconv` hardening are the same code the working-tree
394
+ * diff uses. There is no second diff implementation to keep in sync.
395
+ */
396
+ async commitDiffDocument(input) {
397
+ const root = await this.requireRepositoryRoot();
398
+ const path = assertRelativeGitPath(input.path);
399
+ if ((0, redaction_1.isSensitiveFilePath)(path)) {
400
+ throw new errors_1.WebWorkbenchError(403, 'Sensitive file content is not available in the Web Workbench.', 'sensitive_file_blocked');
401
+ }
402
+ const parentIndex = input.parentIndex ?? 0;
403
+ const detail = await this.commitDetail(input.oid, parentIndex);
404
+ const snapshot = await this.capture();
405
+ const lineLimit = boundedInteger(input.lineLimit ?? DEFAULT_DIFF_LINES, 1, MAX_DIFF_LINES, 'lineLimit');
406
+ const byteLimit = boundedInteger(input.byteLimit ?? DEFAULT_DIFF_BYTES, 1024, MAX_DIFF_BYTES, 'byteLimit');
407
+ // The margin decides which comparison this page belongs to; a different commit or parent
408
+ // is a different document, not a continuation.
409
+ const margin = (0, crypto_1.createHash)('sha256')
410
+ .update(`commit-diff:${detail.id}:${detail.baseOid}:${parentIndex}:${snapshot.revision}`)
411
+ .digest('hex')
412
+ .slice(0, 24);
413
+ const offset = input.cursor ? decodeCommitDiffCursor(input.cursor, margin) : 0;
414
+ const page = await streamDiffPage({
415
+ cwd: root,
416
+ commands: Object.freeze([
417
+ {
418
+ title: 'Commit',
419
+ args: [
420
+ 'diff',
421
+ '--no-ext-diff',
422
+ '--no-textconv',
423
+ '--unified=3',
424
+ detail.baseOid,
425
+ detail.id,
426
+ '--',
427
+ path,
428
+ ],
429
+ },
430
+ ]),
431
+ offset,
432
+ lineLimit,
433
+ byteLimit,
434
+ onProcess: () => {
435
+ this.performance.processCount += 1;
436
+ },
437
+ onBytes: bytes => {
438
+ this.performance.bytesRead += bytes;
439
+ },
440
+ });
441
+ this.performance.itemsParsed += page.linesParsed;
442
+ const document = (0, git_diff_document_1.parseGitDiffDocument)({
443
+ fileToken: `commit_${margin}`,
444
+ path,
445
+ source: 'commit',
446
+ repositoryRevision: snapshot.revision,
447
+ lines: page.lines,
448
+ hasMore: page.hasMore,
449
+ nextCursor: page.hasMore
450
+ ? encodeCommitDiffCursor({ margin, offset: offset + page.returnedLines })
451
+ : null,
452
+ });
453
+ return Object.freeze({ ...document, baseLabel: detail.baseLabel, baseOid: detail.baseOid });
454
+ }
175
455
  async capture() {
176
456
  let root;
177
457
  try {
@@ -193,14 +473,21 @@ class GitReadModelServiceV1 {
193
473
  behind: 0,
194
474
  records: Object.freeze([]),
195
475
  rawStatus: '',
476
+ stagedRecords: 0,
477
+ conflictedRecords: 0,
478
+ indexDigest: (0, crypto_1.createHash)('sha256').update('').digest('hex'),
196
479
  revision,
197
480
  });
198
481
  }
199
- const [rawStatus, branchResult, headResult, upstreamResult] = await Promise.all([
482
+ const [rawStatus, branchResult, headResult, upstreamResult, indexListing] = await Promise.all([
200
483
  this.runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all'], root),
201
484
  this.tryGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], root),
202
485
  this.tryGit(['rev-parse', 'HEAD'], root),
203
486
  this.tryGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], root),
487
+ // v0.3.17 S1 — the index snapshot is what makes a state revision change on
488
+ // stage/unstage even though HEAD does not move. `--stage` yields
489
+ // mode/oid/stage/path for every entry, including unmerged stages.
490
+ this.tryGit(['ls-files', '--stage', '-z'], root),
204
491
  ]);
205
492
  const branch = branchResult?.trim() || null;
206
493
  const head = headResult?.trim() || null;
@@ -215,7 +502,21 @@ class GitReadModelServiceV1 {
215
502
  }
216
503
  const records = parsePorcelainV1(rawStatus);
217
504
  this.performance.itemsParsed += records.length;
505
+ let stagedRecords = 0;
506
+ let conflictedRecords = 0;
507
+ for (const record of records) {
508
+ const sources = recordSources(record);
509
+ if (sources.includes('conflict'))
510
+ conflictedRecords += 1;
511
+ else if (sources.includes('staged'))
512
+ stagedRecords += 1;
513
+ }
218
514
  const fileFingerprints = records.map(record => fingerprintWorktreePath(root, record.path));
515
+ // Hash the index listing on its own so a large index never builds a second full copy
516
+ // of itself inside the JSON payload.
517
+ const indexDigest = (0, crypto_1.createHash)('sha256')
518
+ .update(indexListing ?? '')
519
+ .digest('hex');
219
520
  const revision = (0, crypto_1.createHash)('sha256')
220
521
  .update(JSON.stringify({
221
522
  root,
@@ -226,6 +527,7 @@ class GitReadModelServiceV1 {
226
527
  behind,
227
528
  rawStatus,
228
529
  fileFingerprints,
530
+ indexDigest,
229
531
  }))
230
532
  .digest('hex');
231
533
  return Object.freeze({
@@ -238,68 +540,394 @@ class GitReadModelServiceV1 {
238
540
  behind,
239
541
  records: Object.freeze(records),
240
542
  rawStatus,
543
+ stagedRecords,
544
+ conflictedRecords,
545
+ indexDigest,
241
546
  revision,
242
547
  });
243
548
  }
244
- projectFile(record) {
549
+ projectFile(record, source) {
245
550
  return Object.freeze({
246
- fileId: this.rememberPath(record.path),
551
+ fileId: this.rememberTarget(source, record.path),
247
552
  path: (0, redaction_1.redactTraceText)(record.path),
553
+ source,
248
554
  indexStatus: record.indexStatus,
249
555
  worktreeStatus: record.worktreeStatus,
250
556
  ...(record.renamedFrom ? { renamedFrom: (0, redaction_1.redactTraceText)(record.renamedFrom) } : {}),
251
557
  });
252
558
  }
253
- rememberPath(path) {
254
- const existing = this.idByPath.get(path);
559
+ rememberTarget(source, path) {
560
+ const key = `${source}\u0000${path}`;
561
+ const existing = this.idByKey.get(key);
255
562
  if (existing)
256
563
  return existing;
257
564
  const id = `git_${(0, crypto_1.createHmac)('sha256', this.secret)
258
- .update(path)
565
+ .update(key)
259
566
  .digest('base64url')
260
567
  .slice(0, 32)}`;
261
- this.idByPath.set(path, id);
262
- this.pathById.set(id, path);
568
+ this.idByKey.set(key, id);
569
+ this.targetById.set(id, Object.freeze({ source, path }));
263
570
  return id;
264
571
  }
572
+ /**
573
+ * v0.3.17 S1 — the single place a Git file token becomes a path. Git mutations resolve
574
+ * tokens here instead of going through FileReadService, whose token space is unrelated.
575
+ */
576
+ resolveTarget(fileId) {
577
+ if (typeof fileId !== 'string' || fileId.length === 0 || fileId.length > 256) {
578
+ throw new errors_1.WebWorkbenchError(400, 'A Git file id is invalid.', 'file_id_invalid');
579
+ }
580
+ const target = this.targetById.get(fileId);
581
+ if (!target) {
582
+ throw new errors_1.WebWorkbenchError(404, 'Git file was not found.', 'git_file_not_found');
583
+ }
584
+ return target;
585
+ }
586
+ /**
587
+ * v0.3.17 S1 — resolve Git file tokens for a mutation and enforce the source each
588
+ * action may write. This replaces FileReadService token lookup, whose token space is
589
+ * unrelated to Git and silently rejected every real Git list token.
590
+ */
591
+ resolveMutationPaths(action, fileIds) {
592
+ if (!Array.isArray(fileIds) || fileIds.length === 0 || fileIds.length > 200) {
593
+ throw new errors_1.WebWorkbenchError(400, 'fileIds must list 1 through 200 files.');
594
+ }
595
+ const paths = [];
596
+ for (const fileId of fileIds) {
597
+ const target = this.resolveTarget(fileId);
598
+ const writable = action === 'stage'
599
+ ? target.source === 'unstaged' || target.source === 'untracked'
600
+ : target.source === 'staged';
601
+ if (!writable) {
602
+ // Conflict entries are deliberately not writable in this version (plan G3).
603
+ throw new errors_1.WebWorkbenchError(409, `A ${target.source} file cannot be ${action === 'stage' ? 'staged' : 'unstaged'}.`, 'git_source_not_writable');
604
+ }
605
+ paths.push(target.path);
606
+ }
607
+ assertSafeGitPaths(paths);
608
+ return paths;
609
+ }
265
610
  /** v0.3.12 S3 — guarded stage: paths are host-resolved file ids only. */
266
611
  async stagePaths(paths) {
267
612
  assertSafeGitPaths(paths);
268
613
  if (paths.length === 0)
269
614
  throw new Error('stage requires at least one path.');
270
- await this.mutate(['add', '--', ...paths]);
271
- return { repositoryRevision: await this.revisionAfterMutation() };
615
+ const pathsInfo = await this.resolveIndexPaths();
616
+ await (0, git_index_transaction_1.withIndexTransaction)(pathsInfo, context => this.readTreeEmpty(context), async (context) => {
617
+ await this.runGit(['add', '--', ...paths], pathsInfo.root, 'index', context.env);
618
+ await this.assertIndexMatchesWorktree(pathsInfo.root, context.env, paths);
619
+ });
620
+ return this.mutationResult();
621
+ }
622
+ /**
623
+ * v0.3.17 S3 — prove the index entry for each path really equals what `git add` should
624
+ * have produced.
625
+ *
626
+ * Checking "the path is present in the index" is not enough: the path was usually already
627
+ * there, so a silent no-op would pass. `hash-object --path` applies exactly the filters and
628
+ * attributes git itself would, so comparing the two oids is a precise postcondition — and
629
+ * it is what turns a stat-cache no-op into a loud failure instead of a lost edit.
630
+ */
631
+ async assertIndexMatchesWorktree(root, env, paths) {
632
+ const entries = await this.indexEntries(root, env);
633
+ for (const path of paths) {
634
+ const entry = entries.get(path);
635
+ const absolute = (0, path_1.resolve)(root, path);
636
+ if (!isWithinRoot(absolute, root)) {
637
+ throw new errors_1.WebWorkbenchError(400, 'A path escaped the repository.', 'git_path_unsafe');
638
+ }
639
+ if (!(0, fs_1.existsSync)(absolute)) {
640
+ // A worktree deletion must have dropped the index entry.
641
+ if (entry !== undefined) {
642
+ throw new errors_1.WebWorkbenchError(409, `The index still records a deleted file: ${path}`, 'git_index_verify_failed');
643
+ }
644
+ continue;
645
+ }
646
+ const expected = (await this.runGit(['hash-object', `--path=${path}`, '--', absolute], root, 'index', env)).trim();
647
+ if (entry !== expected) {
648
+ throw new errors_1.WebWorkbenchError(409, `The index did not record the current content of ${path}.`, 'git_index_verify_failed');
649
+ }
650
+ }
272
651
  }
273
652
  async unstagePaths(paths) {
274
653
  assertSafeGitPaths(paths);
275
654
  if (paths.length === 0)
276
655
  throw new Error('unstage requires at least one path.');
277
- await this.mutate(['reset', '-q', '--', ...paths]);
278
- return { repositoryRevision: await this.revisionAfterMutation() };
656
+ const pathsInfo = await this.resolveIndexPaths();
657
+ const head = (await this.tryGit(['rev-parse', '--verify', 'HEAD'], pathsInfo.root)) !== undefined;
658
+ // Captured up front so the postcondition is checked against a frozen expectation.
659
+ const headBlobs = new Map();
660
+ for (const path of paths) {
661
+ headBlobs.set(path, head
662
+ ? ((await this.tryGit(['rev-parse', `HEAD:${path}`], pathsInfo.root))?.trim() ?? null)
663
+ : null);
664
+ }
665
+ await (0, git_index_transaction_1.withIndexTransaction)(pathsInfo, context => this.readTreeEmpty(context), async (context) => {
666
+ // A repository without HEAD cannot `reset`; `rm --cached` is the index operation
667
+ // that matches a first-commit workflow and leaves worktree files alone (plan G3).
668
+ const args = head
669
+ ? ['reset', '-q', '--', ...paths]
670
+ : ['rm', '--cached', '-q', '--', ...paths];
671
+ await this.runGit(args, pathsInfo.root, 'index', context.env);
672
+ const entries = await this.indexEntries(pathsInfo.root, context.env);
673
+ for (const path of paths) {
674
+ const entry = entries.get(path);
675
+ const expected = headBlobs.get(path) ?? null;
676
+ if (expected === null) {
677
+ if (entry !== undefined) {
678
+ throw new errors_1.WebWorkbenchError(409, 'The index did not drop the expected entry.', 'git_index_verify_failed');
679
+ }
680
+ }
681
+ else if (entry !== expected) {
682
+ throw new errors_1.WebWorkbenchError(409, 'The index did not return to the committed version.', 'git_index_verify_failed');
683
+ }
684
+ }
685
+ });
686
+ return this.mutationResult();
279
687
  }
280
- async commit(message) {
281
- const trimmed = message.trim();
282
- if (!trimmed || trimmed.length > 2000 || /[\x00-\x1f]/.test(trimmed)) {
283
- throw new Error('A valid commit message is required.');
688
+ /**
689
+ * v0.3.17 S3 — apply a hunk or line selection to the index only (plan §7.6/§7.8).
690
+ *
691
+ * The document is re-read on the Host, the patch is rebuilt from it, and `--check` runs
692
+ * before the write. The browser only ever sends ids, never patch text.
693
+ */
694
+ async applySelection(input) {
695
+ const document = await this.diffDocument({ fileId: input.fileId });
696
+ const built = (0, git_patch_builder_1.buildSelectionPatch)(document, {
697
+ ...(input.hunkIds ? { hunkIds: input.hunkIds } : {}),
698
+ ...(input.lineIds ? { lineIds: input.lineIds } : {}),
699
+ });
700
+ if (!built.ok) {
701
+ throw new errors_1.WebWorkbenchError(409, built.reason, 'git_patch_not_applicable');
284
702
  }
703
+ // Unstaging a selection is the same patch applied backwards against the index.
704
+ const reverse = document.source === 'staged';
705
+ return this.applyIndexPatch(built.patch, { reverse });
706
+ }
707
+ /**
708
+ * Applies a Host-rebuilt patch to the index only (plan §7.6). The patch never comes from
709
+ * the browser: it is rebuilt from the reviewed document, and `--check` runs first so a
710
+ * patch that cannot apply is rejected before anything is written into the private index.
711
+ */
712
+ async applyIndexPatch(patch, options = {}) {
713
+ if (typeof patch !== 'string' || patch.length === 0 || patch.length > 1000000) {
714
+ throw new errors_1.WebWorkbenchError(400, 'A patch must be a bounded string.', 'git_patch_invalid');
715
+ }
716
+ const pathsInfo = await this.resolveIndexPaths();
717
+ const direction = options.reverse ? ['--reverse'] : [];
718
+ await (0, git_index_transaction_1.withIndexTransaction)(pathsInfo, context => this.readTreeEmpty(context), async (context) => {
719
+ // `--recount` derives line counts from the body, so a partially rebuilt hunk still
720
+ // applies exactly as written. `--check` first: nothing is written on a failed check.
721
+ await this.runGit(['apply', '--cached', '--check', '--recount', ...direction, '-'], pathsInfo.root, 'index', context.env, patch);
722
+ await this.runGit(['apply', '--cached', '--recount', ...direction, '-'], pathsInfo.root, 'index', context.env, patch);
723
+ });
724
+ return this.mutationResult();
725
+ }
726
+ async readTreeEmpty(context) {
285
727
  const root = await this.requireRepositoryRoot();
286
- const porcelain = (await this.runGit(['status', '--porcelain=v1'], root)).trim();
287
- const stagedLines = porcelain
288
- .split('\n')
289
- .filter(line => line.length > 1 && line[0] !== ' ' && line[0] !== '?' && line[0] !== '!');
290
- if (stagedLines.length === 0) {
291
- throw new Error('Nothing is staged to commit.');
728
+ await this.runGit(['read-tree', '--empty'], root, 'index', context.env);
729
+ }
730
+ /** `ls-files --stage -z` parsed into path → blob oid, honouring a private index env. */
731
+ async indexEntries(root, env) {
732
+ const raw = await this.runGit(['ls-files', '--stage', '-z'], root, 'index', env);
733
+ const entries = new Map();
734
+ for (const record of raw.split('\0')) {
735
+ if (!record)
736
+ continue;
737
+ const tab = record.indexOf('\t');
738
+ if (tab < 0)
739
+ continue;
740
+ const meta = record.slice(0, tab).split(' ');
741
+ const path = record.slice(tab + 1);
742
+ // Unmerged stages share a path; the first stage recorded is enough for verification.
743
+ if (!entries.has(path))
744
+ entries.set(path, meta[1] ?? '');
292
745
  }
293
- await this.mutate(['commit', '-m', trimmed]);
294
- const sha = (await this.runGit(['rev-parse', 'HEAD'], root)).trim();
295
- return { repositoryRevision: await this.revisionAfterMutation(), commitSha: sha };
746
+ return entries;
296
747
  }
297
- async mutate(args) {
748
+ /** Resolves root, git dir and the real index path (worktree-aware). */
749
+ async resolveIndexPaths() {
298
750
  const root = await this.requireRepositoryRoot();
299
- const output = await this.runGit(args, root);
300
- if (output) {
301
- // Warnings on stderr are not captured by runGit; treat non-empty as unexpected.
751
+ // `--git-path index` resolves into `.git/worktrees/<name>/index` for a linked worktree,
752
+ // which is the index this workspace actually writes — not the main worktree's.
753
+ const indexPath = (0, path_1.resolve)(root, (await this.runGit(['rev-parse', '--git-path', 'index'], root)).trim());
754
+ const gitDir = (await this.runGit(['rev-parse', '--absolute-git-dir'], root)).trim();
755
+ return { root, gitDir, indexPath };
756
+ }
757
+ async mutationResult() {
758
+ return { repositoryRevision: await this.revisionAfterMutation() };
759
+ }
760
+ /**
761
+ * v0.3.17 S3 — what a commit would actually include.
762
+ *
763
+ * This is deliberately independent of the change-list filter: plan G3 requires the form to
764
+ * show the real index, not the currently filtered view, so the reader can never commit a
765
+ * subset they did not intend.
766
+ */
767
+ async commitPreview() {
768
+ const snapshot = await this.capture();
769
+ if (!snapshot.isRepository || !snapshot.root) {
770
+ throw new errors_1.WebWorkbenchError(409, 'Git repository is unavailable.', 'git_not_repository');
771
+ }
772
+ const root = snapshot.root;
773
+ const entries = [];
774
+ for (const record of snapshot.records) {
775
+ const sources = recordSources(record);
776
+ if (sources.includes('conflict')) {
777
+ entries.push(Object.freeze({
778
+ path: (0, redaction_1.redactTraceText)(record.path),
779
+ source: 'conflict',
780
+ status: `${record.indexStatus}${record.worktreeStatus}`,
781
+ }));
782
+ continue;
783
+ }
784
+ if (sources.includes('staged')) {
785
+ entries.push(Object.freeze({
786
+ path: (0, redaction_1.redactTraceText)(record.path),
787
+ source: 'staged',
788
+ status: `${record.indexStatus}${record.worktreeStatus}`,
789
+ }));
790
+ }
791
+ }
792
+ const stats = await this.stagedNumstat(root);
793
+ const identity = await this.describeCommitConfiguration(root);
794
+ const hasConflict = snapshot.conflictedRecords > 0;
795
+ const blockedReason = hasConflict
796
+ ? '存在未解决的冲突,请先在 Files 中处理。'
797
+ : entries.length === 0
798
+ ? '暂存区为空,没有可提交的改动。'
799
+ : null;
800
+ return Object.freeze({
801
+ repositoryRevision: snapshot.revision,
802
+ branch: snapshot.branch,
803
+ detached: !snapshot.branch,
804
+ entries: Object.freeze(entries),
805
+ additions: stats.additions,
806
+ deletions: stats.deletions,
807
+ filesChanged: entries.length,
808
+ identity,
809
+ canCommit: blockedReason === null,
810
+ blockedReason,
811
+ });
812
+ }
813
+ /** Bounded `--cached --numstat` read; used only for the summary line of the form. */
814
+ async stagedNumstat(root) {
815
+ const raw = await this.tryGit(['diff', '--cached', '--numstat', '--no-ext-diff', '--no-textconv', '-z'], root);
816
+ if (!raw)
817
+ return { additions: 0, deletions: 0 };
818
+ let additions = 0;
819
+ let deletions = 0;
820
+ for (const record of raw.split('\0')) {
821
+ if (!record)
822
+ continue;
823
+ const [added, removed] = record.split('\t');
824
+ // Binary entries report `-` for both counts and contribute nothing to the totals.
825
+ if (/^\d+$/u.test(added ?? ''))
826
+ additions += Number(added);
827
+ if (/^\d+$/u.test(removed ?? ''))
828
+ deletions += Number(removed);
829
+ }
830
+ return { additions, deletions };
831
+ }
832
+ /**
833
+ * v0.3.17 S3 — commit against a frozen expectation of HEAD and the index tree (plan §7.9).
834
+ *
835
+ * Differences from the previous implementation, all deliberate:
836
+ * - a **separate runner** that leaves the repository's own hooks and signing
837
+ * configuration in place, instead of the read hardening that disables hooks;
838
+ * - the message may contain real newlines (summary + body) and is rejected only for
839
+ * control characters that would corrupt a commit object;
840
+ * - the index and HEAD are captured before and re-read after, and any change that is not
841
+ * ours is reported rather than rolled back;
842
+ * - a timeout is not treated as "not committed": the outcome is queried by requestId.
843
+ */
844
+ async commit(input) {
845
+ const message = normalizeCommitMessage(input.summary, input.body);
846
+ if (typeof input.requestId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/u.test(input.requestId)) {
847
+ throw new errors_1.WebWorkbenchError(400, 'requestId is invalid.', 'git_request_id_invalid');
848
+ }
849
+ const pathsInfo = await this.resolveIndexPaths();
850
+ const before = await this.capture();
851
+ if (!before.isRepository) {
852
+ throw new errors_1.WebWorkbenchError(409, 'Git repository is unavailable.', 'git_not_repository');
853
+ }
854
+ if (before.revision !== input.expectedRepositoryRevision) {
855
+ throw new errors_1.WebWorkbenchError(409, 'Repository changed before commit.', 'git_revision_conflict');
856
+ }
857
+ if (before.conflictedRecords > 0) {
858
+ throw new errors_1.WebWorkbenchError(409, 'Unresolved conflicts must be handled before committing.', 'git_conflict_unresolved');
859
+ }
860
+ if (before.stagedRecords === 0) {
861
+ throw new errors_1.WebWorkbenchError(409, 'Nothing is staged to commit.', 'git_nothing_staged');
862
+ }
863
+ const previousHead = before.head;
864
+ const identity = await this.describeCommitConfiguration(pathsInfo.root);
865
+ const startedAt = Date.now();
866
+ try {
867
+ // `-F -` so the message travels as a literal payload; no shell, no argument parsing.
868
+ await this.runGit(['commit', '-F', '-'], pathsInfo.root, 'commit', undefined, message);
302
869
  }
870
+ catch (error) {
871
+ // A timed-out or interrupted commit may still have landed. Always report the truth.
872
+ const recovery = await this.recoverCommit(pathsInfo.root, previousHead);
873
+ if (recovery.committed && recovery.commitSha) {
874
+ return Object.freeze({
875
+ status: 'committed',
876
+ commitSha: recovery.commitSha,
877
+ repositoryRevision: await this.revisionAfterMutation(),
878
+ durationMs: Date.now() - startedAt,
879
+ identity,
880
+ warning: `提交进程报告失败,但提交已实际完成:${describeError(error)}`,
881
+ });
882
+ }
883
+ throw error;
884
+ }
885
+ const after = await this.recoverCommit(pathsInfo.root, previousHead);
886
+ if (!after.committed || !after.commitSha) {
887
+ throw new errors_1.WebWorkbenchError(503, 'The commit did not produce a new HEAD.', 'git_commit_unconfirmed');
888
+ }
889
+ return Object.freeze({
890
+ status: 'committed',
891
+ commitSha: after.commitSha,
892
+ repositoryRevision: await this.revisionAfterMutation(),
893
+ durationMs: Date.now() - startedAt,
894
+ identity,
895
+ });
896
+ }
897
+ /**
898
+ * A commit is confirmed by HEAD having moved to a *new* commit, never by exit code alone.
899
+ * This is what makes the timeout path safe: we ask Git what actually happened.
900
+ */
901
+ async recoverCommit(root, previousHead) {
902
+ const head = (await this.tryGit(['rev-parse', '--verify', 'HEAD'], root))?.trim() ?? null;
903
+ if (!head)
904
+ return { committed: false, commitSha: null };
905
+ if (head === previousHead)
906
+ return { committed: false, commitSha: null };
907
+ return { committed: true, commitSha: head };
908
+ }
909
+ /** Identity and hook configuration the commit will actually run under (plan §7.9). */
910
+ async describeCommitConfiguration(root) {
911
+ const name = (await this.tryGit(['config', 'user.name'], root))?.trim() ?? null;
912
+ const email = (await this.tryGit(['config', 'user.email'], root))?.trim() ?? null;
913
+ const signingKey = (await this.tryGit(['config', 'user.signingkey'], root))?.trim() ?? null;
914
+ const gpgsign = (await this.tryGit(['config', 'commit.gpgsign'], root))?.trim() ?? null;
915
+ // `rev-parse --git-path hooks` accounts for core.hooksPath; a missing directory means no
916
+ // hooks will run, which the form states instead of silently skipping them.
917
+ const hooksPath = (await this.tryGit(['rev-parse', '--git-path', 'hooks'], root))?.trim() ?? null;
918
+ const hooksDir = hooksPath ? (0, path_1.resolve)(root, hooksPath) : null;
919
+ return Object.freeze({
920
+ name,
921
+ email,
922
+ signingKey,
923
+ signingEnabled: gpgsign === 'true',
924
+ hooksDir,
925
+ hooksPresent: hooksDir !== null && (0, fs_1.existsSync)(hooksDir),
926
+ });
927
+ }
928
+ async mutate(args) {
929
+ const root = await this.requireRepositoryRoot();
930
+ await this.runGit(args, root, 'index');
303
931
  }
304
932
  async requireRepositoryRoot() {
305
933
  const snapshot = await this.capture();
@@ -308,41 +936,133 @@ class GitReadModelServiceV1 {
308
936
  }
309
937
  return snapshot.root;
310
938
  }
939
+ /**
940
+ * v0.3.17 S1 — a mutation reports the same state digest `status()` reports. Returning
941
+ * `rev-parse HEAD` was a different value space AND did not move on stage/unstage, so it
942
+ * could never express "the index changed".
943
+ *
944
+ * NOTE: this is post-write verification, not an atomic compare-and-swap. The real
945
+ * check-write window is S3 scope (plan §7.7); until then nothing may claim atomicity.
946
+ */
311
947
  async revisionAfterMutation() {
312
- const root = await this.requireRepositoryRoot();
313
- return this.runGit(['rev-parse', 'HEAD'], root).then(value => value.trim());
948
+ const snapshot = await this.capture();
949
+ if (!snapshot.isRepository)
950
+ throw new Error('Git repository is unavailable.');
951
+ return snapshot.revision;
314
952
  }
315
- runGit(args, cwd) {
953
+ /**
954
+ * v0.3.17 S3 — one exec path, three explicit profiles.
955
+ *
956
+ * `read` keeps every hardening from v0.3.12. `index` relaxes only what staging must
957
+ * resolve (user config for filters/CRLF) while keeping hooks disabled. `commit` is the
958
+ * deliberate exception from plan §7.9: it lets the repository's own hooks and signing
959
+ * configuration run, and gets its own bounded budget instead of the 5s read budget.
960
+ * The profiles are separate on purpose — enabling commit hooks must not relax reads.
961
+ *
962
+ * Implemented with `spawn` rather than `execFile` for two reasons found the hard way:
963
+ * 1. the async `execFile`/`exec` `input` option is silently ignored, so `git apply -`
964
+ * blocked forever waiting for stdin that never closed;
965
+ * 2. a timeout must kill the whole process group, otherwise a slow `pre-commit` hook
966
+ * survives as an orphan while we report failure.
967
+ */
968
+ runGit(args, cwd, profile = 'read', env, input) {
969
+ return this.runGitBytes(args, cwd, profile, env, input).then(buffer => buffer.toString('utf8'));
970
+ }
971
+ /**
972
+ * v0.3.17 S5 — the byte-level primitive behind `runGit`.
973
+ *
974
+ * Historical file content may not be valid UTF-8, so blob reads must not round-trip
975
+ * through a string. Everything else (hardening prefix, profile, byte budget, timeout,
976
+ * process-group kill) is shared, which is why this is the same body and not a copy.
977
+ */
978
+ runGitBytes(args, cwd, profile = 'read', env,
979
+ /** stdin payload, used by `git apply -` and `git commit -F -`. Never browser input. */
980
+ input) {
981
+ const prefix = profile === 'read'
982
+ ? hardenedGitPrefix()
983
+ : profile === 'index'
984
+ ? (0, git_index_transaction_1.writeGitPrefix)()
985
+ : commitGitPrefix();
986
+ const environment = profile === 'read' ? gitEnvironment() : (env ?? (0, git_index_transaction_1.writeGitEnvironment)());
987
+ const timeout = profile === 'commit' ? this.commitTimeoutMs : GIT_TIMEOUT_MS;
316
988
  return new Promise((resolvePromise, reject) => {
317
989
  this.performance.processCount += 1;
318
- (0, child_process_1.execFile)('git', [...hardenedGitPrefix(), ...args], {
990
+ const child = (0, child_process_1.spawn)('git', [...prefix, ...args], {
319
991
  cwd,
320
- encoding: 'utf8',
321
- timeout: GIT_TIMEOUT_MS,
322
- maxBuffer: GIT_MAX_BUFFER,
323
- env: gitEnvironment(),
324
- }, (error, stdout, stderr) => {
325
- this.performance.bytesRead +=
326
- Buffer.byteLength(stdout, 'utf8') + Buffer.byteLength(stderr, 'utf8');
992
+ env: environment,
993
+ stdio: ['pipe', 'pipe', 'pipe'],
994
+ // Own process group, so a timeout can take the hook processes down with it.
995
+ detached: true,
996
+ });
997
+ const stdout = [];
998
+ const stderr = [];
999
+ let bytes = 0;
1000
+ let settled = false;
1001
+ let timedOut = false;
1002
+ let overflowed = false;
1003
+ const timer = setTimeout(() => {
1004
+ timedOut = true;
1005
+ killProcessTree(child);
1006
+ }, timeout);
1007
+ child.stdout.on('data', (chunk) => {
1008
+ bytes += chunk.length;
1009
+ this.performance.bytesRead += chunk.length;
1010
+ if (bytes > GIT_MAX_BUFFER) {
1011
+ overflowed = true;
1012
+ killProcessTree(child);
1013
+ return;
1014
+ }
1015
+ stdout.push(chunk);
1016
+ });
1017
+ child.stderr.on('data', (chunk) => {
1018
+ bytes += chunk.length;
1019
+ this.performance.bytesRead += chunk.length;
1020
+ if (bytes > GIT_MAX_BUFFER) {
1021
+ overflowed = true;
1022
+ killProcessTree(child);
1023
+ return;
1024
+ }
1025
+ stderr.push(chunk);
1026
+ });
1027
+ const settle = (error) => {
1028
+ if (settled)
1029
+ return;
1030
+ settled = true;
1031
+ clearTimeout(timer);
327
1032
  if (error) {
328
- const processError = error;
329
- const timedOut = processError.code === 'ETIMEDOUT' ||
330
- (processError.killed && processError.signal === 'SIGTERM');
331
- const outputTooLarge = processError.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ||
332
- /maxBuffer length exceeded/iu.test(processError.message);
333
- reject(new errors_1.WebWorkbenchError(503, timedOut
334
- ? 'Git command timed out.'
335
- : outputTooLarge
336
- ? 'Git output exceeded the bounded read limit.'
337
- : (0, redaction_1.redactTraceText)(stderr || error.message || 'Git command failed.'), timedOut
338
- ? 'git_timeout'
339
- : outputTooLarge
340
- ? 'git_output_too_large'
341
- : 'git_command_failed'));
1033
+ reject(error);
1034
+ return;
1035
+ }
1036
+ resolvePromise(Buffer.concat(stdout));
1037
+ };
1038
+ child.on('error', (error) => {
1039
+ settle(new errors_1.WebWorkbenchError(503, (0, redaction_1.redactTraceText)(error.message), 'git_command_failed'));
1040
+ });
1041
+ child.on('close', (code) => {
1042
+ if (timedOut) {
1043
+ settle(new errors_1.WebWorkbenchError(503, 'Git command timed out.', 'git_timeout'));
1044
+ return;
1045
+ }
1046
+ if (overflowed) {
1047
+ settle(new errors_1.WebWorkbenchError(503, 'Git output exceeded the bounded read limit.', 'git_output_too_large'));
342
1048
  return;
343
1049
  }
344
- resolvePromise(stdout);
1050
+ if (code === 0) {
1051
+ settle();
1052
+ return;
1053
+ }
1054
+ const message = Buffer.concat(stderr).toString('utf8').trim();
1055
+ settle(new errors_1.WebWorkbenchError(503, (0, redaction_1.redactTraceText)(message || `Git exited with code ${code ?? 'unknown'}.`), 'git_command_failed'));
345
1056
  });
1057
+ if (input === undefined) {
1058
+ child.stdin.end();
1059
+ return;
1060
+ }
1061
+ // stdin must be explicitly closed; a `-` argument otherwise blocks the child forever.
1062
+ child.stdin.on('error', () => {
1063
+ // The child may exit before consuming the payload; the close handler already decided.
1064
+ });
1065
+ child.stdin.end(input, 'utf8');
346
1066
  });
347
1067
  }
348
1068
  async tryGit(args, cwd) {
@@ -358,7 +1078,7 @@ class GitReadModelServiceV1 {
358
1078
  const signature = (0, crypto_1.createHmac)('sha256', this.secret).update(body).digest('base64url');
359
1079
  return `${body}.${signature}`;
360
1080
  }
361
- decodeCursor(cursor, kind, revision, fileId) {
1081
+ decodeCursor(cursor, kind, revision, fileId, scope) {
362
1082
  if (!cursor || cursor.length > 4096)
363
1083
  return invalidCursor();
364
1084
  const [body, encodedSignature, extra] = cursor.split('.');
@@ -377,9 +1097,10 @@ class GitReadModelServiceV1 {
377
1097
  }
378
1098
  try {
379
1099
  const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
380
- if (parsed.version !== 1 ||
1100
+ if (parsed.version !== 2 ||
381
1101
  parsed.kind !== kind ||
382
1102
  parsed.fileId !== fileId ||
1103
+ parsed.scope !== scope ||
383
1104
  !Number.isSafeInteger(parsed.offset) ||
384
1105
  parsed.offset < 0) {
385
1106
  return invalidCursor();
@@ -431,11 +1152,8 @@ function safeGitPath(path) {
431
1152
  !path.startsWith('../') &&
432
1153
  !path.includes('/../'));
433
1154
  }
434
- function isConflict(file) {
435
- return (file.indexStatus === 'U' ||
436
- file.worktreeStatus === 'U' ||
437
- `${file.indexStatus}${file.worktreeStatus}` === 'AA' ||
438
- `${file.indexStatus}${file.worktreeStatus}` === 'DD');
1155
+ function recordStillMatches(record, source) {
1156
+ return recordSources(record).includes(source);
439
1157
  }
440
1158
  function parseCommit(record) {
441
1159
  const [id, shortId, timestamp, authorName, subject] = record.split('\x1f');
@@ -454,38 +1172,128 @@ function parseCommit(record) {
454
1172
  subject: (0, redaction_1.redactTraceText)(subject),
455
1173
  });
456
1174
  }
457
- function diffCommands(record, path) {
458
- const commands = [];
459
- if (isConflictRecord(record) || (record.indexStatus !== ' ' && record.indexStatus !== '?')) {
460
- commands.push({
1175
+ /**
1176
+ * v0.3.17 S1 — the token's source decides the command, so exactly one comparison is
1177
+ * produced. Previously a mixed (`MM`) file emitted both `--cached` and worktree diffs and
1178
+ * the caller merged them into one page, making the two sources indistinguishable.
1179
+ */
1180
+ function diffCommands(path, source, ignoreWhitespace = false, wordDiff = false) {
1181
+ const modes = [
1182
+ ...(ignoreWhitespace ? ['--ignore-all-space'] : []),
1183
+ ...(wordDiff ? ['--word-diff=plain'] : []),
1184
+ ];
1185
+ if (source === 'staged') {
1186
+ return Object.freeze([
1187
+ {
1188
+ title: 'Staged',
1189
+ args: ['diff', '--cached', ...modes, '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
1190
+ },
1191
+ ]);
1192
+ }
1193
+ if (source === 'unstaged') {
1194
+ return Object.freeze([
1195
+ {
1196
+ title: 'Working tree',
1197
+ args: ['diff', ...modes, '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
1198
+ },
1199
+ ]);
1200
+ }
1201
+ if (source === 'untracked') {
1202
+ return Object.freeze([
1203
+ {
1204
+ title: 'Untracked',
1205
+ args: [
1206
+ 'diff',
1207
+ '--no-index',
1208
+ ...modes,
1209
+ '--no-ext-diff',
1210
+ '--no-textconv',
1211
+ '--unified=3',
1212
+ '--',
1213
+ GIT_NULL_DEVICE,
1214
+ path,
1215
+ ],
1216
+ acceptedExitCodes: [0, 1],
1217
+ },
1218
+ ]);
1219
+ }
1220
+ // Conflict version selection (base / ours / theirs) is S5 scope. Until then keep the
1221
+ // pre-existing index-vs-worktree pair so nothing regresses.
1222
+ return Object.freeze([
1223
+ {
461
1224
  title: 'Staged',
462
1225
  args: ['diff', '--cached', '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
463
- });
464
- }
465
- if (isConflictRecord(record) ||
466
- (record.worktreeStatus !== ' ' && record.worktreeStatus !== '?')) {
467
- commands.push({
1226
+ },
1227
+ {
468
1228
  title: 'Working tree',
469
1229
  args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
470
- });
1230
+ },
1231
+ ]);
1232
+ }
1233
+ /** v0.3.17 S1 — every comparison source a status record participates in. */
1234
+ // A status record is always a working-tree state; typing the result narrowly makes the
1235
+ // compiler enforce that no comparison-only source can ever leak into a status bucket.
1236
+ function recordSources(record) {
1237
+ if (isConflictRecord(record))
1238
+ return Object.freeze(['conflict']);
1239
+ if (record.indexStatus === '?')
1240
+ return Object.freeze(['untracked']);
1241
+ const sources = [];
1242
+ if (record.indexStatus !== ' ')
1243
+ sources.push('staged');
1244
+ if (record.worktreeStatus !== ' ')
1245
+ sources.push('unstaged');
1246
+ return Object.freeze(sources);
1247
+ }
1248
+ function countSources(records) {
1249
+ let conflicted = 0;
1250
+ let staged = 0;
1251
+ let unstaged = 0;
1252
+ let untracked = 0;
1253
+ for (const record of records) {
1254
+ for (const source of recordSources(record)) {
1255
+ if (source === 'staged')
1256
+ staged += 1;
1257
+ else if (source === 'unstaged')
1258
+ unstaged += 1;
1259
+ else if (source === 'untracked')
1260
+ untracked += 1;
1261
+ else
1262
+ conflicted += 1;
1263
+ }
471
1264
  }
472
- if (record.indexStatus === '?') {
473
- commands.push({
474
- title: 'Untracked',
475
- args: [
476
- 'diff',
477
- '--no-index',
478
- '--no-ext-diff',
479
- '--no-textconv',
480
- '--unified=3',
481
- '--',
482
- '/dev/null',
483
- path,
484
- ],
485
- acceptedExitCodes: [0, 1],
486
- });
1265
+ return { total: records.length, conflicted, staged, unstaged, untracked };
1266
+ }
1267
+ /**
1268
+ * v0.3.17 S4 — the status group filter accepts only a working-tree source.
1269
+ *
1270
+ * The distinction is load-bearing: passing the wider comparison-source union here would let a
1271
+ * caller ask for the `commit` group and silently get nothing, hiding the mistake. It is
1272
+ * rejected up front with a clear code instead.
1273
+ */
1274
+ function normalizeSource(value) {
1275
+ if (value === undefined || value === null || value === '')
1276
+ return undefined;
1277
+ if (!git_file_source_1.GIT_WORKTREE_SOURCES_V1.includes(value)) {
1278
+ throw new errors_1.WebWorkbenchError(400, 'group must be a known Git file source.', 'git_query_invalid');
1279
+ }
1280
+ return value;
1281
+ }
1282
+ function normalizeQuery(value) {
1283
+ if (value === undefined || value === null || value === '')
1284
+ return undefined;
1285
+ if (typeof value !== 'string' || value.length > 256) {
1286
+ throw new errors_1.WebWorkbenchError(400, 'query must be a string of at most 256 characters.', 'git_query_invalid');
487
1287
  }
488
- return commands;
1288
+ const trimmed = value.trim();
1289
+ return trimmed ? trimmed.toLocaleLowerCase() : undefined;
1290
+ }
1291
+ /** Binds the active filter into the cursor so pages never mix two query scopes. */
1292
+ function statusScope(group, query) {
1293
+ return (0, crypto_1.createHash)('sha256')
1294
+ .update(`${group ?? '*'}\u0000${query ?? '*'}`)
1295
+ .digest('hex')
1296
+ .slice(0, 16);
489
1297
  }
490
1298
  function isConflictRecord(record) {
491
1299
  return (record.indexStatus === 'U' ||
@@ -681,6 +1489,117 @@ function hardenedGitPrefix() {
681
1489
  '--literal-pathspecs',
682
1490
  ];
683
1491
  }
1492
+ /**
1493
+ * v0.3.17 S3 — commit runner prefix.
1494
+ *
1495
+ * This is the one place where hooks are deliberately **not** disabled and the user's own
1496
+ * configuration is authoritative (plan §7.9). It is a separate function, not a parameter on
1497
+ * the read prefix, so enabling commit hooks can never leak into Git reads.
1498
+ */
1499
+ function commitGitPrefix() {
1500
+ return ['-c', 'core.quotepath=false', '--literal-pathspecs'];
1501
+ }
1502
+ /**
1503
+ * v0.3.17 S3 — summary plus optional body.
1504
+ *
1505
+ * The previous validator rejected any newline, which made a real commit body impossible
1506
+ * (plan G3 asks for exactly that fix). What must still be rejected are characters that would
1507
+ * corrupt or truncate the commit object: NUL and other C0 controls, except the newlines and
1508
+ * tabs a message legitimately contains.
1509
+ */
1510
+ function normalizeCommitMessage(summary, body) {
1511
+ if (typeof summary !== 'string') {
1512
+ throw new errors_1.WebWorkbenchError(400, 'A commit summary is required.', 'git_message_invalid');
1513
+ }
1514
+ const trimmedSummary = summary.trim();
1515
+ if (!trimmedSummary) {
1516
+ throw new errors_1.WebWorkbenchError(400, 'A commit summary is required.', 'git_message_invalid');
1517
+ }
1518
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(trimmedSummary)) {
1519
+ throw new errors_1.WebWorkbenchError(400, 'A commit summary must not contain control characters.', 'git_message_invalid');
1520
+ }
1521
+ const trimmedBody = (body ?? '').replace(/\r\n/gu, '\n').trim();
1522
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(trimmedBody)) {
1523
+ throw new errors_1.WebWorkbenchError(400, 'A commit body must not contain control characters.', 'git_message_invalid');
1524
+ }
1525
+ const message = trimmedBody ? `${trimmedSummary}\n\n${trimmedBody}\n` : `${trimmedSummary}\n`;
1526
+ // Bound the whole payload, not just each half.
1527
+ if (message.length > 20000) {
1528
+ throw new errors_1.WebWorkbenchError(400, 'A commit message is too long.', 'git_message_invalid');
1529
+ }
1530
+ return message;
1531
+ }
1532
+ /** Binds the two content-changing diff modes into the pagination cursor scope. */
1533
+ function diffScope(ignoreWhitespace, wordDiff) {
1534
+ return `iw:${ignoreWhitespace ? 1 : 0}:wd:${wordDiff ? 1 : 0}`;
1535
+ }
1536
+ function describeError(error) {
1537
+ return (0, redaction_1.redactTraceText)(error instanceof Error ? error.message : 'unknown error');
1538
+ }
1539
+ /**
1540
+ * v0.3.17 S4 — a repository-relative path coming from a history request.
1541
+ *
1542
+ * Validated, never escaped: anything that could be read as a revision expression, a pathspec
1543
+ * magic prefix or an absolute path is refused outright rather than passed to argv.
1544
+ */
1545
+ function assertRelativeGitPath(path) {
1546
+ if (typeof path !== 'string' || !path || path.length > 1024) {
1547
+ throw new errors_1.WebWorkbenchError(400, 'A file path is required.', 'git_path_invalid');
1548
+ }
1549
+ if (path.startsWith('/') || path.includes('\0') || path.startsWith(':')) {
1550
+ throw new errors_1.WebWorkbenchError(400, 'Unsafe Git path.', 'git_path_unsafe');
1551
+ }
1552
+ const segments = path.split('/');
1553
+ if (segments.some(segment => segment === '..' || segment === '.' || segment === '')) {
1554
+ throw new errors_1.WebWorkbenchError(400, 'Unsafe Git path.', 'git_path_unsafe');
1555
+ }
1556
+ return path;
1557
+ }
1558
+ /**
1559
+ * A commit-diff cursor carries its comparison identity, so a page from a different commit,
1560
+ * base or parent is rejected instead of silently stitched onto the wrong document.
1561
+ */
1562
+ function encodeCommitDiffCursor(input) {
1563
+ return Buffer.from(JSON.stringify({ v: 1, ...input }), 'utf8').toString('base64url');
1564
+ }
1565
+ function decodeCommitDiffCursor(cursor, margin) {
1566
+ let parsed;
1567
+ try {
1568
+ parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
1569
+ }
1570
+ catch {
1571
+ throw new errors_1.WebWorkbenchError(400, 'Diff cursor is not valid.', 'git_cursor_invalid');
1572
+ }
1573
+ const candidate = parsed;
1574
+ if (candidate.margin !== margin ||
1575
+ typeof candidate.offset !== 'number' ||
1576
+ !Number.isInteger(candidate.offset) ||
1577
+ candidate.offset < 0) {
1578
+ throw new errors_1.WebWorkbenchError(409, 'Diff changed; reload before paginating.', 'git_cursor_invalid');
1579
+ }
1580
+ return candidate.offset;
1581
+ }
1582
+ /**
1583
+ * Kills a detached child and everything it spawned.
1584
+ *
1585
+ * A `pre-commit` hook runs as a child of `git`, so killing only `git` on timeout would leave
1586
+ * the hook running and, worse, leave `index.lock` behind if the hook owned it.
1587
+ */
1588
+ function killProcessTree(child) {
1589
+ if (child.pid === undefined)
1590
+ return;
1591
+ try {
1592
+ process.kill(-child.pid, 'SIGKILL');
1593
+ }
1594
+ catch {
1595
+ try {
1596
+ child.kill('SIGKILL');
1597
+ }
1598
+ catch {
1599
+ // Already gone.
1600
+ }
1601
+ }
1602
+ }
684
1603
  function canonicalDirectory(path) {
685
1604
  try {
686
1605
  const canonical = (0, fs_1.realpathSync)((0, path_1.resolve)(path));