@orion-agents/orion-code 0.3.15 → 0.3.18
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 +254 -0
- 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 +138 -0
- package/dist/web/git-read-model-service.d.ts +338 -18
- package/dist/web/git-read-model-service.js +1039 -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-BpsLphLv.js → TerminalPanel-DnWh3Vp2.js} +1 -1
- package/dist/web-client/assets/{index-D3sxrAh_.css → index--hnGXXjj.css} +1 -1
- package/dist/web-client/assets/index-Drja0_BT.js +20 -0
- package/dist/web-client/assets/{useAutoNotice-BeSf9Dfq.js → useAutoNotice-DfQi4G1X.js} +1 -1
- package/dist/web-client/index.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/web-client/assets/DiffViewer-C0A42Lma.js +0 -5
- package/dist/web-client/assets/FilesPanel-DiXKnFMj.js +0 -1
- package/dist/web-client/assets/GitPanel--_Yrbldp.js +0 -1
- package/dist/web-client/assets/ReviewPanel-BilzL9Ma.js +0 -1
- 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
|
-
|
|
26
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
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:
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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:
|
|
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
|
|
119
|
-
|
|
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(
|
|
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:
|
|
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,404 @@ 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.
|
|
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
|
-
|
|
254
|
-
const
|
|
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(
|
|
565
|
+
.update(key)
|
|
259
566
|
.digest('base64url')
|
|
260
567
|
.slice(0, 32)}`;
|
|
261
|
-
this.
|
|
262
|
-
this.
|
|
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.
|
|
271
|
-
|
|
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.
|
|
278
|
-
|
|
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();
|
|
687
|
+
}
|
|
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');
|
|
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 });
|
|
279
706
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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');
|
|
284
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).catch(error => {
|
|
722
|
+
const message = error instanceof Error ? error.message : '';
|
|
723
|
+
// A patch rebuilt from the reviewed document can still be inapplicable to the real
|
|
724
|
+
// index. CRLF line endings are the common cause: the diff text cannot represent them
|
|
725
|
+
// unambiguously, so the rebuilt context does not match the stored bytes. The plan
|
|
726
|
+
// requires such a selection to be refused explicitly rather than to fail as a Git
|
|
727
|
+
// error — nothing has been written at this point.
|
|
728
|
+
throw new errors_1.WebWorkbenchError(409, /patch failed|patch does not apply/iu.test(message)
|
|
729
|
+
? 'This selection cannot be applied to the index (CRLF line endings are a common cause).'
|
|
730
|
+
: message, 'git_patch_not_applicable');
|
|
731
|
+
});
|
|
732
|
+
await this.runGit(['apply', '--cached', '--recount', ...direction, '-'], pathsInfo.root, 'index', context.env, patch);
|
|
733
|
+
});
|
|
734
|
+
return this.mutationResult();
|
|
735
|
+
}
|
|
736
|
+
async readTreeEmpty(context) {
|
|
285
737
|
const root = await this.requireRepositoryRoot();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
738
|
+
await this.runGit(['read-tree', '--empty'], root, 'index', context.env);
|
|
739
|
+
}
|
|
740
|
+
/** `ls-files --stage -z` parsed into path → blob oid, honouring a private index env. */
|
|
741
|
+
async indexEntries(root, env) {
|
|
742
|
+
const raw = await this.runGit(['ls-files', '--stage', '-z'], root, 'index', env);
|
|
743
|
+
const entries = new Map();
|
|
744
|
+
for (const record of raw.split('\0')) {
|
|
745
|
+
if (!record)
|
|
746
|
+
continue;
|
|
747
|
+
const tab = record.indexOf('\t');
|
|
748
|
+
if (tab < 0)
|
|
749
|
+
continue;
|
|
750
|
+
const meta = record.slice(0, tab).split(' ');
|
|
751
|
+
const path = record.slice(tab + 1);
|
|
752
|
+
// Unmerged stages share a path; the first stage recorded is enough for verification.
|
|
753
|
+
if (!entries.has(path))
|
|
754
|
+
entries.set(path, meta[1] ?? '');
|
|
292
755
|
}
|
|
293
|
-
|
|
294
|
-
const sha = (await this.runGit(['rev-parse', 'HEAD'], root)).trim();
|
|
295
|
-
return { repositoryRevision: await this.revisionAfterMutation(), commitSha: sha };
|
|
756
|
+
return entries;
|
|
296
757
|
}
|
|
297
|
-
|
|
758
|
+
/** Resolves root, git dir and the real index path (worktree-aware). */
|
|
759
|
+
async resolveIndexPaths() {
|
|
298
760
|
const root = await this.requireRepositoryRoot();
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
761
|
+
// `--git-path index` resolves into `.git/worktrees/<name>/index` for a linked worktree,
|
|
762
|
+
// which is the index this workspace actually writes — not the main worktree's.
|
|
763
|
+
const indexPath = (0, path_1.resolve)(root, (await this.runGit(['rev-parse', '--git-path', 'index'], root)).trim());
|
|
764
|
+
const gitDir = (await this.runGit(['rev-parse', '--absolute-git-dir'], root)).trim();
|
|
765
|
+
return { root, gitDir, indexPath };
|
|
766
|
+
}
|
|
767
|
+
async mutationResult() {
|
|
768
|
+
return { repositoryRevision: await this.revisionAfterMutation() };
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* v0.3.17 S3 — what a commit would actually include.
|
|
772
|
+
*
|
|
773
|
+
* This is deliberately independent of the change-list filter: plan G3 requires the form to
|
|
774
|
+
* show the real index, not the currently filtered view, so the reader can never commit a
|
|
775
|
+
* subset they did not intend.
|
|
776
|
+
*/
|
|
777
|
+
async commitPreview() {
|
|
778
|
+
const snapshot = await this.capture();
|
|
779
|
+
if (!snapshot.isRepository || !snapshot.root) {
|
|
780
|
+
throw new errors_1.WebWorkbenchError(409, 'Git repository is unavailable.', 'git_not_repository');
|
|
781
|
+
}
|
|
782
|
+
const root = snapshot.root;
|
|
783
|
+
const entries = [];
|
|
784
|
+
for (const record of snapshot.records) {
|
|
785
|
+
const sources = recordSources(record);
|
|
786
|
+
if (sources.includes('conflict')) {
|
|
787
|
+
entries.push(Object.freeze({
|
|
788
|
+
path: (0, redaction_1.redactTraceText)(record.path),
|
|
789
|
+
source: 'conflict',
|
|
790
|
+
status: `${record.indexStatus}${record.worktreeStatus}`,
|
|
791
|
+
}));
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (sources.includes('staged')) {
|
|
795
|
+
entries.push(Object.freeze({
|
|
796
|
+
path: (0, redaction_1.redactTraceText)(record.path),
|
|
797
|
+
source: 'staged',
|
|
798
|
+
status: `${record.indexStatus}${record.worktreeStatus}`,
|
|
799
|
+
}));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const stats = await this.stagedNumstat(root);
|
|
803
|
+
const identity = await this.describeCommitConfiguration(root);
|
|
804
|
+
const hasConflict = snapshot.conflictedRecords > 0;
|
|
805
|
+
const blockedReason = hasConflict
|
|
806
|
+
? '存在未解决的冲突,请先在 Files 中处理。'
|
|
807
|
+
: entries.length === 0
|
|
808
|
+
? '暂存区为空,没有可提交的改动。'
|
|
809
|
+
: null;
|
|
810
|
+
return Object.freeze({
|
|
811
|
+
repositoryRevision: snapshot.revision,
|
|
812
|
+
branch: snapshot.branch,
|
|
813
|
+
detached: !snapshot.branch,
|
|
814
|
+
entries: Object.freeze(entries),
|
|
815
|
+
additions: stats.additions,
|
|
816
|
+
deletions: stats.deletions,
|
|
817
|
+
filesChanged: entries.length,
|
|
818
|
+
identity,
|
|
819
|
+
canCommit: blockedReason === null,
|
|
820
|
+
blockedReason,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/** Bounded `--cached --numstat` read; used only for the summary line of the form. */
|
|
824
|
+
async stagedNumstat(root) {
|
|
825
|
+
const raw = await this.tryGit(['diff', '--cached', '--numstat', '--no-ext-diff', '--no-textconv', '-z'], root);
|
|
826
|
+
if (!raw)
|
|
827
|
+
return { additions: 0, deletions: 0 };
|
|
828
|
+
let additions = 0;
|
|
829
|
+
let deletions = 0;
|
|
830
|
+
for (const record of raw.split('\0')) {
|
|
831
|
+
if (!record)
|
|
832
|
+
continue;
|
|
833
|
+
const [added, removed] = record.split('\t');
|
|
834
|
+
// Binary entries report `-` for both counts and contribute nothing to the totals.
|
|
835
|
+
if (/^\d+$/u.test(added ?? ''))
|
|
836
|
+
additions += Number(added);
|
|
837
|
+
if (/^\d+$/u.test(removed ?? ''))
|
|
838
|
+
deletions += Number(removed);
|
|
839
|
+
}
|
|
840
|
+
return { additions, deletions };
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* v0.3.17 S3 — commit against a frozen expectation of HEAD and the index tree (plan §7.9).
|
|
844
|
+
*
|
|
845
|
+
* Differences from the previous implementation, all deliberate:
|
|
846
|
+
* - a **separate runner** that leaves the repository's own hooks and signing
|
|
847
|
+
* configuration in place, instead of the read hardening that disables hooks;
|
|
848
|
+
* - the message may contain real newlines (summary + body) and is rejected only for
|
|
849
|
+
* control characters that would corrupt a commit object;
|
|
850
|
+
* - the index and HEAD are captured before and re-read after, and any change that is not
|
|
851
|
+
* ours is reported rather than rolled back;
|
|
852
|
+
* - a timeout is not treated as "not committed": the outcome is queried by requestId.
|
|
853
|
+
*/
|
|
854
|
+
async commit(input) {
|
|
855
|
+
const message = normalizeCommitMessage(input.summary, input.body);
|
|
856
|
+
if (typeof input.requestId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/u.test(input.requestId)) {
|
|
857
|
+
throw new errors_1.WebWorkbenchError(400, 'requestId is invalid.', 'git_request_id_invalid');
|
|
858
|
+
}
|
|
859
|
+
const pathsInfo = await this.resolveIndexPaths();
|
|
860
|
+
const before = await this.capture();
|
|
861
|
+
if (!before.isRepository) {
|
|
862
|
+
throw new errors_1.WebWorkbenchError(409, 'Git repository is unavailable.', 'git_not_repository');
|
|
863
|
+
}
|
|
864
|
+
if (before.revision !== input.expectedRepositoryRevision) {
|
|
865
|
+
throw new errors_1.WebWorkbenchError(409, 'Repository changed before commit.', 'git_revision_conflict');
|
|
866
|
+
}
|
|
867
|
+
if (before.conflictedRecords > 0) {
|
|
868
|
+
throw new errors_1.WebWorkbenchError(409, 'Unresolved conflicts must be handled before committing.', 'git_conflict_unresolved');
|
|
869
|
+
}
|
|
870
|
+
if (before.stagedRecords === 0) {
|
|
871
|
+
throw new errors_1.WebWorkbenchError(409, 'Nothing is staged to commit.', 'git_nothing_staged');
|
|
872
|
+
}
|
|
873
|
+
const previousHead = before.head;
|
|
874
|
+
const identity = await this.describeCommitConfiguration(pathsInfo.root);
|
|
875
|
+
const startedAt = Date.now();
|
|
876
|
+
try {
|
|
877
|
+
// `-F -` so the message travels as a literal payload; no shell, no argument parsing.
|
|
878
|
+
await this.runGit(['commit', '-F', '-'], pathsInfo.root, 'commit', undefined, message);
|
|
302
879
|
}
|
|
880
|
+
catch (error) {
|
|
881
|
+
// A timed-out or interrupted commit may still have landed. Always report the truth.
|
|
882
|
+
const recovery = await this.recoverCommit(pathsInfo.root, previousHead);
|
|
883
|
+
if (recovery.committed && recovery.commitSha) {
|
|
884
|
+
return Object.freeze({
|
|
885
|
+
status: 'committed',
|
|
886
|
+
commitSha: recovery.commitSha,
|
|
887
|
+
repositoryRevision: await this.revisionAfterMutation(),
|
|
888
|
+
durationMs: Date.now() - startedAt,
|
|
889
|
+
identity,
|
|
890
|
+
warning: `提交进程报告失败,但提交已实际完成:${describeError(error)}`,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
throw error;
|
|
894
|
+
}
|
|
895
|
+
const after = await this.recoverCommit(pathsInfo.root, previousHead);
|
|
896
|
+
if (!after.committed || !after.commitSha) {
|
|
897
|
+
throw new errors_1.WebWorkbenchError(503, 'The commit did not produce a new HEAD.', 'git_commit_unconfirmed');
|
|
898
|
+
}
|
|
899
|
+
return Object.freeze({
|
|
900
|
+
status: 'committed',
|
|
901
|
+
commitSha: after.commitSha,
|
|
902
|
+
repositoryRevision: await this.revisionAfterMutation(),
|
|
903
|
+
durationMs: Date.now() - startedAt,
|
|
904
|
+
identity,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* A commit is confirmed by HEAD having moved to a *new* commit, never by exit code alone.
|
|
909
|
+
* This is what makes the timeout path safe: we ask Git what actually happened.
|
|
910
|
+
*/
|
|
911
|
+
async recoverCommit(root, previousHead) {
|
|
912
|
+
const head = (await this.tryGit(['rev-parse', '--verify', 'HEAD'], root))?.trim() ?? null;
|
|
913
|
+
if (!head)
|
|
914
|
+
return { committed: false, commitSha: null };
|
|
915
|
+
if (head === previousHead)
|
|
916
|
+
return { committed: false, commitSha: null };
|
|
917
|
+
return { committed: true, commitSha: head };
|
|
918
|
+
}
|
|
919
|
+
/** Identity and hook configuration the commit will actually run under (plan §7.9). */
|
|
920
|
+
async describeCommitConfiguration(root) {
|
|
921
|
+
const name = (await this.tryGit(['config', 'user.name'], root))?.trim() ?? null;
|
|
922
|
+
const email = (await this.tryGit(['config', 'user.email'], root))?.trim() ?? null;
|
|
923
|
+
const signingKey = (await this.tryGit(['config', 'user.signingkey'], root))?.trim() ?? null;
|
|
924
|
+
const gpgsign = (await this.tryGit(['config', 'commit.gpgsign'], root))?.trim() ?? null;
|
|
925
|
+
// `rev-parse --git-path hooks` accounts for core.hooksPath; a missing directory means no
|
|
926
|
+
// hooks will run, which the form states instead of silently skipping them.
|
|
927
|
+
const hooksPath = (await this.tryGit(['rev-parse', '--git-path', 'hooks'], root))?.trim() ?? null;
|
|
928
|
+
const hooksDir = hooksPath ? (0, path_1.resolve)(root, hooksPath) : null;
|
|
929
|
+
return Object.freeze({
|
|
930
|
+
name,
|
|
931
|
+
email,
|
|
932
|
+
signingKey,
|
|
933
|
+
signingEnabled: gpgsign === 'true',
|
|
934
|
+
hooksDir,
|
|
935
|
+
hooksPresent: hooksDir !== null && (0, fs_1.existsSync)(hooksDir),
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
async mutate(args) {
|
|
939
|
+
const root = await this.requireRepositoryRoot();
|
|
940
|
+
await this.runGit(args, root, 'index');
|
|
303
941
|
}
|
|
304
942
|
async requireRepositoryRoot() {
|
|
305
943
|
const snapshot = await this.capture();
|
|
@@ -308,41 +946,133 @@ class GitReadModelServiceV1 {
|
|
|
308
946
|
}
|
|
309
947
|
return snapshot.root;
|
|
310
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* v0.3.17 S1 — a mutation reports the same state digest `status()` reports. Returning
|
|
951
|
+
* `rev-parse HEAD` was a different value space AND did not move on stage/unstage, so it
|
|
952
|
+
* could never express "the index changed".
|
|
953
|
+
*
|
|
954
|
+
* NOTE: this is post-write verification, not an atomic compare-and-swap. The real
|
|
955
|
+
* check-write window is S3 scope (plan §7.7); until then nothing may claim atomicity.
|
|
956
|
+
*/
|
|
311
957
|
async revisionAfterMutation() {
|
|
312
|
-
const
|
|
313
|
-
|
|
958
|
+
const snapshot = await this.capture();
|
|
959
|
+
if (!snapshot.isRepository)
|
|
960
|
+
throw new Error('Git repository is unavailable.');
|
|
961
|
+
return snapshot.revision;
|
|
314
962
|
}
|
|
315
|
-
|
|
963
|
+
/**
|
|
964
|
+
* v0.3.17 S3 — one exec path, three explicit profiles.
|
|
965
|
+
*
|
|
966
|
+
* `read` keeps every hardening from v0.3.12. `index` relaxes only what staging must
|
|
967
|
+
* resolve (user config for filters/CRLF) while keeping hooks disabled. `commit` is the
|
|
968
|
+
* deliberate exception from plan §7.9: it lets the repository's own hooks and signing
|
|
969
|
+
* configuration run, and gets its own bounded budget instead of the 5s read budget.
|
|
970
|
+
* The profiles are separate on purpose — enabling commit hooks must not relax reads.
|
|
971
|
+
*
|
|
972
|
+
* Implemented with `spawn` rather than `execFile` for two reasons found the hard way:
|
|
973
|
+
* 1. the async `execFile`/`exec` `input` option is silently ignored, so `git apply -`
|
|
974
|
+
* blocked forever waiting for stdin that never closed;
|
|
975
|
+
* 2. a timeout must kill the whole process group, otherwise a slow `pre-commit` hook
|
|
976
|
+
* survives as an orphan while we report failure.
|
|
977
|
+
*/
|
|
978
|
+
runGit(args, cwd, profile = 'read', env, input) {
|
|
979
|
+
return this.runGitBytes(args, cwd, profile, env, input).then(buffer => buffer.toString('utf8'));
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* v0.3.17 S5 — the byte-level primitive behind `runGit`.
|
|
983
|
+
*
|
|
984
|
+
* Historical file content may not be valid UTF-8, so blob reads must not round-trip
|
|
985
|
+
* through a string. Everything else (hardening prefix, profile, byte budget, timeout,
|
|
986
|
+
* process-group kill) is shared, which is why this is the same body and not a copy.
|
|
987
|
+
*/
|
|
988
|
+
runGitBytes(args, cwd, profile = 'read', env,
|
|
989
|
+
/** stdin payload, used by `git apply -` and `git commit -F -`. Never browser input. */
|
|
990
|
+
input) {
|
|
991
|
+
const prefix = profile === 'read'
|
|
992
|
+
? hardenedGitPrefix()
|
|
993
|
+
: profile === 'index'
|
|
994
|
+
? (0, git_index_transaction_1.writeGitPrefix)()
|
|
995
|
+
: commitGitPrefix();
|
|
996
|
+
const environment = profile === 'read' ? gitEnvironment() : (env ?? (0, git_index_transaction_1.writeGitEnvironment)());
|
|
997
|
+
const timeout = profile === 'commit' ? this.commitTimeoutMs : GIT_TIMEOUT_MS;
|
|
316
998
|
return new Promise((resolvePromise, reject) => {
|
|
317
999
|
this.performance.processCount += 1;
|
|
318
|
-
(0, child_process_1.
|
|
1000
|
+
const child = (0, child_process_1.spawn)('git', [...prefix, ...args], {
|
|
319
1001
|
cwd,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
1002
|
+
env: environment,
|
|
1003
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1004
|
+
// Own process group, so a timeout can take the hook processes down with it.
|
|
1005
|
+
detached: true,
|
|
1006
|
+
});
|
|
1007
|
+
const stdout = [];
|
|
1008
|
+
const stderr = [];
|
|
1009
|
+
let bytes = 0;
|
|
1010
|
+
let settled = false;
|
|
1011
|
+
let timedOut = false;
|
|
1012
|
+
let overflowed = false;
|
|
1013
|
+
const timer = setTimeout(() => {
|
|
1014
|
+
timedOut = true;
|
|
1015
|
+
killProcessTree(child);
|
|
1016
|
+
}, timeout);
|
|
1017
|
+
child.stdout.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
|
+
stdout.push(chunk);
|
|
1026
|
+
});
|
|
1027
|
+
child.stderr.on('data', (chunk) => {
|
|
1028
|
+
bytes += chunk.length;
|
|
1029
|
+
this.performance.bytesRead += chunk.length;
|
|
1030
|
+
if (bytes > GIT_MAX_BUFFER) {
|
|
1031
|
+
overflowed = true;
|
|
1032
|
+
killProcessTree(child);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
stderr.push(chunk);
|
|
1036
|
+
});
|
|
1037
|
+
const settle = (error) => {
|
|
1038
|
+
if (settled)
|
|
1039
|
+
return;
|
|
1040
|
+
settled = true;
|
|
1041
|
+
clearTimeout(timer);
|
|
327
1042
|
if (error) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
1043
|
+
reject(error);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
resolvePromise(Buffer.concat(stdout));
|
|
1047
|
+
};
|
|
1048
|
+
child.on('error', (error) => {
|
|
1049
|
+
settle(new errors_1.WebWorkbenchError(503, (0, redaction_1.redactTraceText)(error.message), 'git_command_failed'));
|
|
1050
|
+
});
|
|
1051
|
+
child.on('close', (code) => {
|
|
1052
|
+
if (timedOut) {
|
|
1053
|
+
settle(new errors_1.WebWorkbenchError(503, 'Git command timed out.', 'git_timeout'));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
if (overflowed) {
|
|
1057
|
+
settle(new errors_1.WebWorkbenchError(503, 'Git output exceeded the bounded read limit.', 'git_output_too_large'));
|
|
342
1058
|
return;
|
|
343
1059
|
}
|
|
344
|
-
|
|
1060
|
+
if (code === 0) {
|
|
1061
|
+
settle();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const message = Buffer.concat(stderr).toString('utf8').trim();
|
|
1065
|
+
settle(new errors_1.WebWorkbenchError(503, (0, redaction_1.redactTraceText)(message || `Git exited with code ${code ?? 'unknown'}.`), 'git_command_failed'));
|
|
345
1066
|
});
|
|
1067
|
+
if (input === undefined) {
|
|
1068
|
+
child.stdin.end();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
// stdin must be explicitly closed; a `-` argument otherwise blocks the child forever.
|
|
1072
|
+
child.stdin.on('error', () => {
|
|
1073
|
+
// The child may exit before consuming the payload; the close handler already decided.
|
|
1074
|
+
});
|
|
1075
|
+
child.stdin.end(input, 'utf8');
|
|
346
1076
|
});
|
|
347
1077
|
}
|
|
348
1078
|
async tryGit(args, cwd) {
|
|
@@ -358,7 +1088,7 @@ class GitReadModelServiceV1 {
|
|
|
358
1088
|
const signature = (0, crypto_1.createHmac)('sha256', this.secret).update(body).digest('base64url');
|
|
359
1089
|
return `${body}.${signature}`;
|
|
360
1090
|
}
|
|
361
|
-
decodeCursor(cursor, kind, revision, fileId) {
|
|
1091
|
+
decodeCursor(cursor, kind, revision, fileId, scope) {
|
|
362
1092
|
if (!cursor || cursor.length > 4096)
|
|
363
1093
|
return invalidCursor();
|
|
364
1094
|
const [body, encodedSignature, extra] = cursor.split('.');
|
|
@@ -377,9 +1107,10 @@ class GitReadModelServiceV1 {
|
|
|
377
1107
|
}
|
|
378
1108
|
try {
|
|
379
1109
|
const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
380
|
-
if (parsed.version !==
|
|
1110
|
+
if (parsed.version !== 2 ||
|
|
381
1111
|
parsed.kind !== kind ||
|
|
382
1112
|
parsed.fileId !== fileId ||
|
|
1113
|
+
parsed.scope !== scope ||
|
|
383
1114
|
!Number.isSafeInteger(parsed.offset) ||
|
|
384
1115
|
parsed.offset < 0) {
|
|
385
1116
|
return invalidCursor();
|
|
@@ -431,11 +1162,8 @@ function safeGitPath(path) {
|
|
|
431
1162
|
!path.startsWith('../') &&
|
|
432
1163
|
!path.includes('/../'));
|
|
433
1164
|
}
|
|
434
|
-
function
|
|
435
|
-
return (
|
|
436
|
-
file.worktreeStatus === 'U' ||
|
|
437
|
-
`${file.indexStatus}${file.worktreeStatus}` === 'AA' ||
|
|
438
|
-
`${file.indexStatus}${file.worktreeStatus}` === 'DD');
|
|
1165
|
+
function recordStillMatches(record, source) {
|
|
1166
|
+
return recordSources(record).includes(source);
|
|
439
1167
|
}
|
|
440
1168
|
function parseCommit(record) {
|
|
441
1169
|
const [id, shortId, timestamp, authorName, subject] = record.split('\x1f');
|
|
@@ -454,38 +1182,128 @@ function parseCommit(record) {
|
|
|
454
1182
|
subject: (0, redaction_1.redactTraceText)(subject),
|
|
455
1183
|
});
|
|
456
1184
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1185
|
+
/**
|
|
1186
|
+
* v0.3.17 S1 — the token's source decides the command, so exactly one comparison is
|
|
1187
|
+
* produced. Previously a mixed (`MM`) file emitted both `--cached` and worktree diffs and
|
|
1188
|
+
* the caller merged them into one page, making the two sources indistinguishable.
|
|
1189
|
+
*/
|
|
1190
|
+
function diffCommands(path, source, ignoreWhitespace = false, wordDiff = false) {
|
|
1191
|
+
const modes = [
|
|
1192
|
+
...(ignoreWhitespace ? ['--ignore-all-space'] : []),
|
|
1193
|
+
...(wordDiff ? ['--word-diff=plain'] : []),
|
|
1194
|
+
];
|
|
1195
|
+
if (source === 'staged') {
|
|
1196
|
+
return Object.freeze([
|
|
1197
|
+
{
|
|
1198
|
+
title: 'Staged',
|
|
1199
|
+
args: ['diff', '--cached', ...modes, '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
|
|
1200
|
+
},
|
|
1201
|
+
]);
|
|
1202
|
+
}
|
|
1203
|
+
if (source === 'unstaged') {
|
|
1204
|
+
return Object.freeze([
|
|
1205
|
+
{
|
|
1206
|
+
title: 'Working tree',
|
|
1207
|
+
args: ['diff', ...modes, '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
|
|
1208
|
+
},
|
|
1209
|
+
]);
|
|
1210
|
+
}
|
|
1211
|
+
if (source === 'untracked') {
|
|
1212
|
+
return Object.freeze([
|
|
1213
|
+
{
|
|
1214
|
+
title: 'Untracked',
|
|
1215
|
+
args: [
|
|
1216
|
+
'diff',
|
|
1217
|
+
'--no-index',
|
|
1218
|
+
...modes,
|
|
1219
|
+
'--no-ext-diff',
|
|
1220
|
+
'--no-textconv',
|
|
1221
|
+
'--unified=3',
|
|
1222
|
+
'--',
|
|
1223
|
+
GIT_NULL_DEVICE,
|
|
1224
|
+
path,
|
|
1225
|
+
],
|
|
1226
|
+
acceptedExitCodes: [0, 1],
|
|
1227
|
+
},
|
|
1228
|
+
]);
|
|
1229
|
+
}
|
|
1230
|
+
// Conflict version selection (base / ours / theirs) is S5 scope. Until then keep the
|
|
1231
|
+
// pre-existing index-vs-worktree pair so nothing regresses.
|
|
1232
|
+
return Object.freeze([
|
|
1233
|
+
{
|
|
461
1234
|
title: 'Staged',
|
|
462
1235
|
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({
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
468
1238
|
title: 'Working tree',
|
|
469
1239
|
args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--', path],
|
|
470
|
-
}
|
|
1240
|
+
},
|
|
1241
|
+
]);
|
|
1242
|
+
}
|
|
1243
|
+
/** v0.3.17 S1 — every comparison source a status record participates in. */
|
|
1244
|
+
// A status record is always a working-tree state; typing the result narrowly makes the
|
|
1245
|
+
// compiler enforce that no comparison-only source can ever leak into a status bucket.
|
|
1246
|
+
function recordSources(record) {
|
|
1247
|
+
if (isConflictRecord(record))
|
|
1248
|
+
return Object.freeze(['conflict']);
|
|
1249
|
+
if (record.indexStatus === '?')
|
|
1250
|
+
return Object.freeze(['untracked']);
|
|
1251
|
+
const sources = [];
|
|
1252
|
+
if (record.indexStatus !== ' ')
|
|
1253
|
+
sources.push('staged');
|
|
1254
|
+
if (record.worktreeStatus !== ' ')
|
|
1255
|
+
sources.push('unstaged');
|
|
1256
|
+
return Object.freeze(sources);
|
|
1257
|
+
}
|
|
1258
|
+
function countSources(records) {
|
|
1259
|
+
let conflicted = 0;
|
|
1260
|
+
let staged = 0;
|
|
1261
|
+
let unstaged = 0;
|
|
1262
|
+
let untracked = 0;
|
|
1263
|
+
for (const record of records) {
|
|
1264
|
+
for (const source of recordSources(record)) {
|
|
1265
|
+
if (source === 'staged')
|
|
1266
|
+
staged += 1;
|
|
1267
|
+
else if (source === 'unstaged')
|
|
1268
|
+
unstaged += 1;
|
|
1269
|
+
else if (source === 'untracked')
|
|
1270
|
+
untracked += 1;
|
|
1271
|
+
else
|
|
1272
|
+
conflicted += 1;
|
|
1273
|
+
}
|
|
471
1274
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
1275
|
+
return { total: records.length, conflicted, staged, unstaged, untracked };
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* v0.3.17 S4 — the status group filter accepts only a working-tree source.
|
|
1279
|
+
*
|
|
1280
|
+
* The distinction is load-bearing: passing the wider comparison-source union here would let a
|
|
1281
|
+
* caller ask for the `commit` group and silently get nothing, hiding the mistake. It is
|
|
1282
|
+
* rejected up front with a clear code instead.
|
|
1283
|
+
*/
|
|
1284
|
+
function normalizeSource(value) {
|
|
1285
|
+
if (value === undefined || value === null || value === '')
|
|
1286
|
+
return undefined;
|
|
1287
|
+
if (!git_file_source_1.GIT_WORKTREE_SOURCES_V1.includes(value)) {
|
|
1288
|
+
throw new errors_1.WebWorkbenchError(400, 'group must be a known Git file source.', 'git_query_invalid');
|
|
1289
|
+
}
|
|
1290
|
+
return value;
|
|
1291
|
+
}
|
|
1292
|
+
function normalizeQuery(value) {
|
|
1293
|
+
if (value === undefined || value === null || value === '')
|
|
1294
|
+
return undefined;
|
|
1295
|
+
if (typeof value !== 'string' || value.length > 256) {
|
|
1296
|
+
throw new errors_1.WebWorkbenchError(400, 'query must be a string of at most 256 characters.', 'git_query_invalid');
|
|
487
1297
|
}
|
|
488
|
-
|
|
1298
|
+
const trimmed = value.trim();
|
|
1299
|
+
return trimmed ? trimmed.toLocaleLowerCase() : undefined;
|
|
1300
|
+
}
|
|
1301
|
+
/** Binds the active filter into the cursor so pages never mix two query scopes. */
|
|
1302
|
+
function statusScope(group, query) {
|
|
1303
|
+
return (0, crypto_1.createHash)('sha256')
|
|
1304
|
+
.update(`${group ?? '*'}\u0000${query ?? '*'}`)
|
|
1305
|
+
.digest('hex')
|
|
1306
|
+
.slice(0, 16);
|
|
489
1307
|
}
|
|
490
1308
|
function isConflictRecord(record) {
|
|
491
1309
|
return (record.indexStatus === 'U' ||
|
|
@@ -681,6 +1499,117 @@ function hardenedGitPrefix() {
|
|
|
681
1499
|
'--literal-pathspecs',
|
|
682
1500
|
];
|
|
683
1501
|
}
|
|
1502
|
+
/**
|
|
1503
|
+
* v0.3.17 S3 — commit runner prefix.
|
|
1504
|
+
*
|
|
1505
|
+
* This is the one place where hooks are deliberately **not** disabled and the user's own
|
|
1506
|
+
* configuration is authoritative (plan §7.9). It is a separate function, not a parameter on
|
|
1507
|
+
* the read prefix, so enabling commit hooks can never leak into Git reads.
|
|
1508
|
+
*/
|
|
1509
|
+
function commitGitPrefix() {
|
|
1510
|
+
return ['-c', 'core.quotepath=false', '--literal-pathspecs'];
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* v0.3.17 S3 — summary plus optional body.
|
|
1514
|
+
*
|
|
1515
|
+
* The previous validator rejected any newline, which made a real commit body impossible
|
|
1516
|
+
* (plan G3 asks for exactly that fix). What must still be rejected are characters that would
|
|
1517
|
+
* corrupt or truncate the commit object: NUL and other C0 controls, except the newlines and
|
|
1518
|
+
* tabs a message legitimately contains.
|
|
1519
|
+
*/
|
|
1520
|
+
function normalizeCommitMessage(summary, body) {
|
|
1521
|
+
if (typeof summary !== 'string') {
|
|
1522
|
+
throw new errors_1.WebWorkbenchError(400, 'A commit summary is required.', 'git_message_invalid');
|
|
1523
|
+
}
|
|
1524
|
+
const trimmedSummary = summary.trim();
|
|
1525
|
+
if (!trimmedSummary) {
|
|
1526
|
+
throw new errors_1.WebWorkbenchError(400, 'A commit summary is required.', 'git_message_invalid');
|
|
1527
|
+
}
|
|
1528
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(trimmedSummary)) {
|
|
1529
|
+
throw new errors_1.WebWorkbenchError(400, 'A commit summary must not contain control characters.', 'git_message_invalid');
|
|
1530
|
+
}
|
|
1531
|
+
const trimmedBody = (body ?? '').replace(/\r\n/gu, '\n').trim();
|
|
1532
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(trimmedBody)) {
|
|
1533
|
+
throw new errors_1.WebWorkbenchError(400, 'A commit body must not contain control characters.', 'git_message_invalid');
|
|
1534
|
+
}
|
|
1535
|
+
const message = trimmedBody ? `${trimmedSummary}\n\n${trimmedBody}\n` : `${trimmedSummary}\n`;
|
|
1536
|
+
// Bound the whole payload, not just each half.
|
|
1537
|
+
if (message.length > 20000) {
|
|
1538
|
+
throw new errors_1.WebWorkbenchError(400, 'A commit message is too long.', 'git_message_invalid');
|
|
1539
|
+
}
|
|
1540
|
+
return message;
|
|
1541
|
+
}
|
|
1542
|
+
/** Binds the two content-changing diff modes into the pagination cursor scope. */
|
|
1543
|
+
function diffScope(ignoreWhitespace, wordDiff) {
|
|
1544
|
+
return `iw:${ignoreWhitespace ? 1 : 0}:wd:${wordDiff ? 1 : 0}`;
|
|
1545
|
+
}
|
|
1546
|
+
function describeError(error) {
|
|
1547
|
+
return (0, redaction_1.redactTraceText)(error instanceof Error ? error.message : 'unknown error');
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* v0.3.17 S4 — a repository-relative path coming from a history request.
|
|
1551
|
+
*
|
|
1552
|
+
* Validated, never escaped: anything that could be read as a revision expression, a pathspec
|
|
1553
|
+
* magic prefix or an absolute path is refused outright rather than passed to argv.
|
|
1554
|
+
*/
|
|
1555
|
+
function assertRelativeGitPath(path) {
|
|
1556
|
+
if (typeof path !== 'string' || !path || path.length > 1024) {
|
|
1557
|
+
throw new errors_1.WebWorkbenchError(400, 'A file path is required.', 'git_path_invalid');
|
|
1558
|
+
}
|
|
1559
|
+
if (path.startsWith('/') || path.includes('\0') || path.startsWith(':')) {
|
|
1560
|
+
throw new errors_1.WebWorkbenchError(400, 'Unsafe Git path.', 'git_path_unsafe');
|
|
1561
|
+
}
|
|
1562
|
+
const segments = path.split('/');
|
|
1563
|
+
if (segments.some(segment => segment === '..' || segment === '.' || segment === '')) {
|
|
1564
|
+
throw new errors_1.WebWorkbenchError(400, 'Unsafe Git path.', 'git_path_unsafe');
|
|
1565
|
+
}
|
|
1566
|
+
return path;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* A commit-diff cursor carries its comparison identity, so a page from a different commit,
|
|
1570
|
+
* base or parent is rejected instead of silently stitched onto the wrong document.
|
|
1571
|
+
*/
|
|
1572
|
+
function encodeCommitDiffCursor(input) {
|
|
1573
|
+
return Buffer.from(JSON.stringify({ v: 1, ...input }), 'utf8').toString('base64url');
|
|
1574
|
+
}
|
|
1575
|
+
function decodeCommitDiffCursor(cursor, margin) {
|
|
1576
|
+
let parsed;
|
|
1577
|
+
try {
|
|
1578
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
throw new errors_1.WebWorkbenchError(400, 'Diff cursor is not valid.', 'git_cursor_invalid');
|
|
1582
|
+
}
|
|
1583
|
+
const candidate = parsed;
|
|
1584
|
+
if (candidate.margin !== margin ||
|
|
1585
|
+
typeof candidate.offset !== 'number' ||
|
|
1586
|
+
!Number.isInteger(candidate.offset) ||
|
|
1587
|
+
candidate.offset < 0) {
|
|
1588
|
+
throw new errors_1.WebWorkbenchError(409, 'Diff changed; reload before paginating.', 'git_cursor_invalid');
|
|
1589
|
+
}
|
|
1590
|
+
return candidate.offset;
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Kills a detached child and everything it spawned.
|
|
1594
|
+
*
|
|
1595
|
+
* A `pre-commit` hook runs as a child of `git`, so killing only `git` on timeout would leave
|
|
1596
|
+
* the hook running and, worse, leave `index.lock` behind if the hook owned it.
|
|
1597
|
+
*/
|
|
1598
|
+
function killProcessTree(child) {
|
|
1599
|
+
if (child.pid === undefined)
|
|
1600
|
+
return;
|
|
1601
|
+
try {
|
|
1602
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
1603
|
+
}
|
|
1604
|
+
catch {
|
|
1605
|
+
try {
|
|
1606
|
+
child.kill('SIGKILL');
|
|
1607
|
+
}
|
|
1608
|
+
catch {
|
|
1609
|
+
// Already gone.
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
684
1613
|
function canonicalDirectory(path) {
|
|
685
1614
|
try {
|
|
686
1615
|
const canonical = (0, fs_1.realpathSync)((0, path_1.resolve)(path));
|