@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.
- package/CHANGELOG.md +219 -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 +134 -0
- package/dist/web/git-read-model-service.d.ts +338 -18
- package/dist/web/git-read-model-service.js +1029 -110
- package/dist/web/git-version-service.d.ts +71 -0
- package/dist/web/git-version-service.js +255 -0
- package/dist/web/native-directory-picker.d.ts +63 -0
- package/dist/web/native-directory-picker.js +169 -0
- package/dist/web/protocol.d.ts +51 -2
- package/dist/web/server.js +384 -8
- package/dist/web/workbench-controller.d.ts +126 -10
- package/dist/web/workbench-controller.js +383 -15
- package/dist/web/workspace-open-telemetry.d.ts +55 -0
- package/dist/web/workspace-open-telemetry.js +73 -0
- package/dist/web-client/assets/FilesPanel-BARQCmne.js +1 -0
- package/dist/web-client/assets/GitPanel-DjB9eSiJ.js +4 -0
- package/dist/web-client/assets/ReviewPanel-YhEGlp-G.js +5 -0
- package/dist/web-client/assets/{TerminalPanel-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,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.
|
|
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();
|
|
279
687
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
+
/** Resolves root, git dir and the real index path (worktree-aware). */
|
|
749
|
+
async resolveIndexPaths() {
|
|
298
750
|
const root = await this.requireRepositoryRoot();
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
313
|
-
|
|
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
|
-
|
|
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.
|
|
990
|
+
const child = (0, child_process_1.spawn)('git', [...prefix, ...args], {
|
|
319
991
|
cwd,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
435
|
-
return (
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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));
|