@myagentroam/node 0.11.35 → 0.11.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/application/execution-lifecycle.js +54 -0
  2. package/dist/application/node-lifecycle.js +104 -0
  3. package/dist/application/node-operation-admission.js +51 -0
  4. package/dist/application/node-operation-contracts.js +1 -0
  5. package/dist/application/runner-composition.js +22 -0
  6. package/dist/application/session-projection-composition.js +26 -0
  7. package/dist/application/workspace-application.js +61 -0
  8. package/dist/application/workspace-composition.js +21 -0
  9. package/dist/capabilities.js +2 -2
  10. package/dist/codex-app-server.js +1 -471
  11. package/dist/connection/authorization.js +26 -0
  12. package/dist/{service → connection}/direct-transfer-service.js +1 -1
  13. package/dist/connection/endpoints.js +32 -0
  14. package/dist/connector.js +144 -252
  15. package/dist/database.js +34 -14
  16. package/dist/git-askpass.js +1 -1
  17. package/dist/git-execution-helper.js +1 -1
  18. package/dist/hosting/hosted-conversation-file-identity.js +27 -0
  19. package/dist/hosting/hosted-conversation-file-operations.js +44 -0
  20. package/dist/hosting/hosted-conversation-node-service.js +974 -0
  21. package/dist/hosting/hosted-conversation-runs.js +599 -0
  22. package/dist/hosting/hosted-conversation-templates.js +280 -0
  23. package/dist/hosting/hosted-conversation-uploads.js +344 -0
  24. package/dist/hosting/hosted-conversation-watch.js +194 -0
  25. package/dist/hosting/runtime-contracts.js +1 -0
  26. package/dist/marctl-guidance.js +1 -1
  27. package/dist/runner/abstract-runner.js +4 -13
  28. package/dist/{claude-agent-sdk.js → runner/claude/claude-agent-sdk.js} +2 -2
  29. package/dist/runner/claude/conversation-parser.js +2 -1
  30. package/dist/runner/claude/managed-run-controller.js +6 -7
  31. package/dist/runner/claude/message-parsers.js +2 -1
  32. package/dist/runner/claude/native-transcript.js +2 -1
  33. package/dist/runner/claude/tool-normalizer.js +1 -1
  34. package/dist/runner/claude-code-runner.js +2 -2
  35. package/dist/runner/codex/codex-app-server.js +471 -0
  36. package/dist/runner/codex/conversation-parser.js +4 -2
  37. package/dist/runner/codex/managed-run-controller.js +7 -9
  38. package/dist/runner/codex/native-transcript.js +1 -1
  39. package/dist/runner/codex/tool-normalizer.js +1 -1
  40. package/dist/runner/codex-runner.js +9 -27
  41. package/dist/runner/contracts.js +1 -0
  42. package/dist/runner/mar-agent/conversation-normalizer.js +2 -2
  43. package/dist/runner/mar-agent/history-projection.js +1 -1
  44. package/dist/runner/mar-agent/hosted-conversation-runtime.js +6 -10
  45. package/dist/runner/mar-agent/managed-run-controller.js +85 -61
  46. package/dist/runner/mar-agent-runner.js +1 -1
  47. package/dist/runner/normalization.js +42 -0
  48. package/dist/runner/opencode/conversation-parser.js +1 -1
  49. package/dist/runner/opencode/managed-run-controller.js +6 -7
  50. package/dist/{opencode-server.js → runner/opencode/opencode-server.js} +2 -2
  51. package/dist/runner/opencode/tool-normalizer.js +1 -1
  52. package/dist/runner/opencode-runner.js +4 -5
  53. package/dist/runner/runner-registry.js +4 -1
  54. package/dist/runtime-state.js +48 -372
  55. package/dist/service/computer-use-enablement-service.js +1 -1
  56. package/dist/service/continuous-run-coordinator.js +52 -7
  57. package/dist/service/conversation-history-service.js +4 -4
  58. package/dist/service/conversation-hosting-capability-service.js +1 -1
  59. package/dist/service/conversation-hosting-enablement-service.js +1 -1
  60. package/dist/service/conversation-hosting-lifecycle-service.js +3 -3
  61. package/dist/service/conversation-hosting-maintenance-service.js +1 -1
  62. package/dist/service/conversation-observation.js +1 -1
  63. package/dist/service/conversation-segment-service.js +1 -1
  64. package/dist/service/external-session-resume-service.js +47 -27
  65. package/dist/service/fake-managed-run-service.js +3 -7
  66. package/dist/service/git-operation-service.js +1 -1
  67. package/dist/service/goal-continuation-coordinator.js +14 -7
  68. package/dist/service/mar-agent-model-transport-test-service.js +1 -1
  69. package/dist/service/native-session-watch-service.js +1 -1
  70. package/dist/service/node-control-channel.js +1 -5
  71. package/dist/service/node-request-service.js +1 -1
  72. package/dist/service/node-terminal-channel.js +2 -1
  73. package/dist/service/node-workspace-coordinator.js +3 -2
  74. package/dist/service/notification-mcp-bridge.js +6 -2
  75. package/dist/service/recent-run-projection-service.js +1 -1
  76. package/dist/service/restart-session-restorer.js +1 -1
  77. package/dist/service/run-event-service.js +49 -42
  78. package/dist/service/run-workbench-service.js +3 -3
  79. package/dist/service/runner-interaction-service.js +6 -5
  80. package/dist/service/runner-service.js +1 -1
  81. package/dist/service/session-recent-activity.js +2 -1
  82. package/dist/service/skill-directory-service.js +1 -1
  83. package/dist/service/skill-install-service.js +2 -2
  84. package/dist/service/terminal-service.js +1 -1
  85. package/dist/service/workbench-event-projection.js +3 -2
  86. package/dist/service/workbench-event-service.js +1 -1
  87. package/dist/service/workspace-service.js +3 -2
  88. package/dist/session/attachments.js +110 -0
  89. package/dist/session/contracts.js +1 -0
  90. package/dist/{util → session}/conversation-identity.js +1 -1
  91. package/dist/session/input-validation.js +15 -0
  92. package/dist/{service → session}/native-session-projection-service.js +40 -11
  93. package/dist/session/pagination.js +77 -0
  94. package/dist/{util → session}/runner-native-session-parsers.js +2 -1
  95. package/dist/session/runtime/approvals.js +90 -0
  96. package/dist/session/runtime/conversation-projection.js +310 -0
  97. package/dist/session/runtime/type-guards.js +19 -0
  98. package/dist/{service → session}/session-catalog-service.js +5 -4
  99. package/dist/{service → session}/session-command-service.js +3 -3
  100. package/dist/{service → session}/session-identity-service.js +1 -1
  101. package/dist/{service → session}/session-lifecycle-service.js +18 -7
  102. package/dist/{service → session}/session-message-service.js +6 -5
  103. package/dist/{service → session}/session-presentation-service.js +1 -1
  104. package/dist/{service → session}/session-query-service.js +2 -2
  105. package/dist/{service → session}/session-schedule-service.js +2 -2
  106. package/dist/util/records.js +3 -0
  107. package/dist/workspace/contracts.js +1 -0
  108. package/dist/workspace/file-index.js +534 -0
  109. package/dist/workspace/files.js +466 -0
  110. package/dist/workspace/git.js +991 -0
  111. package/dist/workspace/path-access.js +184 -0
  112. package/dist/workspace/utf8.js +12 -0
  113. package/dist/{service → workspace}/workspace-change-service.js +45 -21
  114. package/dist/{service → workspace}/workspace-code-navigation-service.js +6 -6
  115. package/dist/{service → workspace}/workspace-file-manager-service.js +1 -1
  116. package/dist/{service → workspace}/workspace-file-service.js +16 -16
  117. package/dist/{service → workspace}/workspace-git-exclude-service.js +1 -1
  118. package/dist/{service → workspace}/workspace-queue-workbench-service.js +3 -2
  119. package/dist/{service → workspace}/workspace-session-service.js +45 -40
  120. package/dist/{service → workspace}/workspace-upload-service.js +28 -28
  121. package/dist/{service → workspace}/workspace-watch-service.js +29 -28
  122. package/dist/{service → workspace}/workspace-workbench-service.js +4 -3
  123. package/package.json +4 -4
  124. package/dist/service/hosted-conversation-node-service.js +0 -2252
  125. package/dist/service/run-domain-state.js +0 -4
  126. package/dist/service/session-domain-state.js +0 -9
  127. package/dist/service/workspace-domain-state.js +0 -41
  128. package/dist/util/node-operation-parsers.js +0 -320
  129. package/dist/workspace.js +0 -2168
  130. /package/dist/{connector → application}/node-connector-options.js +0 -0
  131. /package/dist/{util → connection}/node-operation-data.js +0 -0
  132. /package/dist/{connector → connection}/node-operation-router.js +0 -0
  133. /package/dist/{service → hosting}/hosted-conversation-sandbox-profile.js +0 -0
  134. /package/dist/{service → hosting}/hosted-conversation-store.js +0 -0
  135. /package/dist/{util → mcp}/secret-environment.js +0 -0
  136. /package/dist/{claude-channel.js → runner/claude/claude-channel.js} +0 -0
  137. /package/dist/{util → runner}/personal-instructions.js +0 -0
  138. /package/dist/{util → runner}/runner-error.js +0 -0
  139. /package/dist/{service/conversation-work-process.js → session/runtime/work-process.js} +0 -0
  140. /package/dist/{service → session}/session-context-service.js +0 -0
  141. /package/dist/{util → workspace}/git-credential-client.js +0 -0
  142. /package/dist/{service → workspace}/workspace-content-search-service.js +0 -0
  143. /package/dist/{service → workspace}/workspace-watch-snapshot-service.js +0 -0
@@ -0,0 +1,991 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
3
+ import { basename, resolve } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { logNodeCommandFailure } from '../operational.js';
6
+ import { terminateProcessTree } from '../process-tree.js';
7
+ import { gitOperationEnvironment } from '../service/git-operation-service.js';
8
+ import { isWithinAllowedRoot, normalizeWorkspacePath, resolveRegisteredWorkspaceRoot, resolveWorkspaceEntry } from './path-access.js';
9
+ import { isUtf8ContinuationByte, isValidUtf8 } from './utf8.js';
10
+ const GIT_TIMEOUT_MS = 5_000;
11
+ const GIT_STATUS_TIMEOUT_MS = 30_000;
12
+ const GIT_HISTORY_TIMEOUT_MS = 15_000;
13
+ const GIT_OUTPUT_LIMIT = 512 * 1024;
14
+ const GIT_LARGE_OUTPUT_LIMIT = 8 * 1024 * 1024;
15
+ const GIT_WORKTREE_LIST_LIMIT = 128;
16
+ const REF_PATTERN = /^(?!-)(?!.*\.\.)(?!.*@\{)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
17
+ export async function inspectWorkspace(requestedPath, allowedRoots, includeParentRepository = true) {
18
+ const resolvedPath = await realpath(requestedPath);
19
+ const resolvedRoots = await Promise.all(allowedRoots.map((root) => realpath(root)));
20
+ const directory = await stat(resolvedPath);
21
+ if (!directory.isDirectory() || !isWithinAllowedRoot(resolvedPath, resolvedRoots)) {
22
+ throw new Error('WORKSPACE_NOT_ALLOWED');
23
+ }
24
+ try {
25
+ const repositoryRoot = await resolveWorkspaceGitRoot(resolvedPath, includeParentRepository);
26
+ const summary = { ...(await readGitSummary(repositoryRoot)), repositoryRoot };
27
+ return { path: resolvedPath, kind: 'GIT_WORKSPACE', gitSummary: summary };
28
+ }
29
+ catch (error) {
30
+ if ((error instanceof GitCommandError && error.code === 128) ||
31
+ isGitUnavailable(error) ||
32
+ (error instanceof Error && error.message === 'WORKSPACE_NOT_GIT')) {
33
+ return { path: resolvedPath, kind: 'DIRECTORY' };
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+ /** Resolves the working tree without changing the registered Workspace identity. */
39
+ export async function resolveWorkspaceGitRoot(workspacePath, includeParentRepository = true) {
40
+ const workspace = await resolveRegisteredWorkspaceRoot(workspacePath);
41
+ if (!includeParentRepository) {
42
+ const marker = await lstat(resolve(workspace, '.git')).catch((error) => {
43
+ if (error.code === 'ENOENT')
44
+ return undefined;
45
+ throw error;
46
+ });
47
+ if (marker === undefined ||
48
+ marker.isSymbolicLink() ||
49
+ (!marker.isFile() && !marker.isDirectory()))
50
+ throw new Error('WORKSPACE_NOT_GIT');
51
+ }
52
+ const result = await runReadOnlyGit(workspace, ['rev-parse', '--show-toplevel']);
53
+ const root = await realpath(result.text.replace(/\n$/u, ''));
54
+ if (!includeParentRepository &&
55
+ normalizeWorkspacePath(root) !== normalizeWorkspacePath(workspace))
56
+ throw new Error('WORKSPACE_NOT_GIT');
57
+ return root;
58
+ }
59
+ export async function readGitSummary(workspacePath) {
60
+ const commonDir = (await runReadOnlyGit(workspacePath, ['rev-parse', '--git-common-dir'])).text.trim();
61
+ const headResult = await runReadOnlyGitAllowFailure(workspacePath, [
62
+ 'rev-parse',
63
+ '--verify',
64
+ 'HEAD'
65
+ ]);
66
+ const branchResult = await runReadOnlyGitAllowFailure(workspacePath, [
67
+ 'symbolic-ref',
68
+ '--quiet',
69
+ '--short',
70
+ 'HEAD'
71
+ ]);
72
+ const statusOptions = {
73
+ timeoutMs: GIT_STATUS_TIMEOUT_MS,
74
+ outputLimit: GIT_LARGE_OUTPUT_LIMIT
75
+ };
76
+ const status = await runReadOnlyGit(workspacePath, ['status', '--porcelain=v1'], statusOptions);
77
+ return {
78
+ commonDir,
79
+ head: headResult.code === 0 ? headResult.text.trim() : '',
80
+ branch: branchResult.code === 0 ? branchResult.text.trim() : null,
81
+ dirty: status.output.length > 0,
82
+ snapshot: summarizeText(status.text)
83
+ };
84
+ }
85
+ export function assertSafeGitRef(ref) {
86
+ if (!REF_PATTERN.test(ref)) {
87
+ throw new Error('GIT_REF_INVALID');
88
+ }
89
+ }
90
+ export async function readRestrictedDiff(workspacePath, fromRef, toRef) {
91
+ assertSafeGitRef(fromRef);
92
+ assertSafeGitRef(toRef);
93
+ const result = await runReadOnlyGit(workspacePath, [
94
+ 'diff',
95
+ '--no-ext-diff',
96
+ '--no-textconv',
97
+ fromRef,
98
+ toRef
99
+ ]);
100
+ const binary = isBinaryGitPatch(result.text);
101
+ if (binary) {
102
+ return {
103
+ binary: true,
104
+ summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
105
+ truncated: result.truncated
106
+ };
107
+ }
108
+ return { binary: false, text: result.text, truncated: result.truncated };
109
+ }
110
+ function currentBranchFromStatusHeader(header) {
111
+ const value = header.slice(3);
112
+ if (value.startsWith('HEAD '))
113
+ return null;
114
+ const unborn = /^(?:No commits yet on|Initial commit on) (.+)$/.exec(value);
115
+ if (unborn !== null)
116
+ return unborn[1] ?? null;
117
+ return value.split('...')[0]?.split(' ')[0] ?? null;
118
+ }
119
+ function trackingFromStatusHeader(header) {
120
+ const match = /^## [^ ]+\.\.\.([^ ]+)(?: \[(ahead \d+)?(?:, )?(behind \d+)?\])?$/.exec(header);
121
+ if (match?.[1] === undefined)
122
+ return undefined;
123
+ return {
124
+ upstream: match[1],
125
+ ahead: Number(match[2]?.slice(6) ?? 0),
126
+ behind: Number(match[3]?.slice(7) ?? 0)
127
+ };
128
+ }
129
+ export async function listGitWorktrees(workspacePath) {
130
+ const options = {
131
+ timeoutMs: GIT_STATUS_TIMEOUT_MS,
132
+ outputLimit: GIT_LARGE_OUTPUT_LIMIT
133
+ };
134
+ const nulDelimited = await runReadOnlyGitAllowFailure(workspacePath, ['worktree', 'list', '--porcelain', '-z'], options);
135
+ if (nulDelimited.code === 0)
136
+ return parseGitWorktreeList(workspacePath, nulDelimited, true);
137
+ if (!canRetryGitWorktreeListWithoutNullOutput(nulDelimited))
138
+ throw gitCommandFailure(nulDelimited.code, nulDelimited.stderr, 'worktree');
139
+ const lineDelimited = await runReadOnlyGit(workspacePath, ['worktree', 'list', '--porcelain'], options);
140
+ return parseGitWorktreeList(workspacePath, lineDelimited, false);
141
+ }
142
+ async function parseGitWorktreeList(workspacePath, result, nulDelimited) {
143
+ const currentPath = normalizeWorkspacePath(await realpath(workspacePath));
144
+ const entries = [];
145
+ let truncated = result.truncated;
146
+ const groups = nulDelimited ? result.text.split('\0\0') : result.text.split(/\r?\n\r?\n/);
147
+ for (const group of groups) {
148
+ if (group.length === 0)
149
+ continue;
150
+ if (entries.length >= GIT_WORKTREE_LIST_LIMIT) {
151
+ truncated = true;
152
+ break;
153
+ }
154
+ const fields = (nulDelimited ? group.split('\0') : group.split(/\r?\n/)).filter((field) => field.length > 0);
155
+ if (!nulDelimited &&
156
+ fields.some((field) => !(field.startsWith('worktree ') ||
157
+ field.startsWith('HEAD ') ||
158
+ field.startsWith('branch ') ||
159
+ field === 'bare' ||
160
+ field === 'detached' ||
161
+ field === 'locked' ||
162
+ field.startsWith('locked ') ||
163
+ field === 'prunable' ||
164
+ field.startsWith('prunable ')))) {
165
+ throw new Error('GIT_WORKTREE_LIST_INVALID');
166
+ }
167
+ if (fields.some((field) => field === 'bare'))
168
+ continue;
169
+ const pathField = fields.find((field) => field.startsWith('worktree '));
170
+ const headField = fields.find((field) => field.startsWith('HEAD '));
171
+ const path = pathField?.slice('worktree '.length);
172
+ const head = headField?.slice('HEAD '.length);
173
+ if (path === undefined ||
174
+ path.length === 0 ||
175
+ head === undefined ||
176
+ !/^[0-9a-f]{40,64}$/u.test(head)) {
177
+ if (result.truncated) {
178
+ truncated = true;
179
+ continue;
180
+ }
181
+ throw new Error('GIT_WORKTREE_LIST_INVALID');
182
+ }
183
+ const branchField = fields.find((field) => field.startsWith('branch '));
184
+ const branchRef = branchField?.slice('branch '.length);
185
+ const branch = branchRef === undefined
186
+ ? null
187
+ : branchRef.startsWith('refs/heads/')
188
+ ? branchRef.slice('refs/heads/'.length)
189
+ : branchRef;
190
+ const directoryName = (basename(path).trim() || branch || head.slice(0, 12)).slice(0, 512);
191
+ entries.push({
192
+ path,
193
+ directoryName,
194
+ branch,
195
+ head,
196
+ current: normalizeWorkspacePath(path) === currentPath,
197
+ locked: fields.some((field) => field === 'locked' || field.startsWith('locked ')),
198
+ prunable: fields.some((field) => field === 'prunable' || field.startsWith('prunable '))
199
+ });
200
+ }
201
+ return { entries, truncated };
202
+ }
203
+ export async function readCurrentChangesSummary(workspacePath, isolateSubmoduleFailures = false) {
204
+ const args = [
205
+ 'status',
206
+ '--porcelain=v1',
207
+ '-z',
208
+ '--branch',
209
+ '--ahead-behind',
210
+ '--untracked-files=all'
211
+ ];
212
+ const options = { timeoutMs: GIT_STATUS_TIMEOUT_MS, outputLimit: GIT_LARGE_OUTPUT_LIMIT };
213
+ const result = await runReadOnlyGit(workspacePath, args, options).catch((error) => {
214
+ if (!isolateSubmoduleFailures || !(error instanceof GitCommandError))
215
+ throw error;
216
+ // Validate root facts independently when descending into a damaged child fails.
217
+ // A second failure remains a repository failure, never an empty success.
218
+ return runReadOnlyGit(workspacePath, [...args, '--ignore-submodules=dirty'], options);
219
+ });
220
+ const records = result.text.split('\0');
221
+ if (result.truncated)
222
+ records.pop();
223
+ const header = records.shift() ?? '';
224
+ const output = [];
225
+ for (let index = 0; index < records.length; index += 1) {
226
+ const record = records[index];
227
+ if (record.length < 4)
228
+ continue;
229
+ const indexState = record[0];
230
+ const worktreeState = record[1];
231
+ const path = record.slice(3);
232
+ if (indexState === '?' && worktreeState === '?') {
233
+ output.push({ path, state: 'UNTRACKED', change: 'ADDED' });
234
+ continue;
235
+ }
236
+ if (indexState !== ' ') {
237
+ output.push({ path, state: 'STAGED', change: gitChange(indexState) });
238
+ }
239
+ if (worktreeState !== ' ') {
240
+ output.push({ path, state: 'UNSTAGED', change: gitChange(worktreeState) });
241
+ }
242
+ // Porcelain v1 -z emits a second NUL-delimited source path for rename
243
+ // and copy records. It is metadata for the same entry, never another
244
+ // status record.
245
+ if (indexState === 'R' || indexState === 'C' || worktreeState === 'R' || worktreeState === 'C')
246
+ index += 1;
247
+ }
248
+ const tracking = trackingFromStatusHeader(header);
249
+ const branch = header.startsWith('## ') ? currentBranchFromStatusHeader(header) : null;
250
+ const head = branch === null
251
+ ? (await runReadOnlyGit(workspacePath, ['rev-parse', '--verify', 'HEAD'])).text.trim()
252
+ : undefined;
253
+ return {
254
+ branch,
255
+ ...(head === undefined ? {} : { head }),
256
+ ...(tracking === undefined ? {} : { tracking }),
257
+ ...(result.truncated ? { changesTruncated: true } : {}),
258
+ changes: output
259
+ };
260
+ }
261
+ export async function listGitSubmodules(workspacePath) {
262
+ const result = await runReadOnlyGit(workspacePath, ['ls-files', '--stage', '-z'], {
263
+ timeoutMs: GIT_STATUS_TIMEOUT_MS,
264
+ outputLimit: GIT_LARGE_OUTPUT_LIMIT
265
+ });
266
+ if (result.truncated)
267
+ throw new Error('GIT_SUBMODULE_DISCOVERY_TRUNCATED');
268
+ const paths = [
269
+ ...new Set(result.text.split('\0').flatMap((record) => {
270
+ const match = /^160000 [0-9a-f]+ [0-3]\t([\s\S]+)$/.exec(record);
271
+ return match?.[1] === undefined ? [] : [match[1]];
272
+ }))
273
+ ].sort((left, right) => left.localeCompare(right));
274
+ if (paths.length === 0)
275
+ return [];
276
+ const unavailable = (mapping, gitExitCode) => paths.map((path) => ({ path, mapping, ...(gitExitCode === undefined ? {} : { gitExitCode }) }));
277
+ try {
278
+ const modulesFile = await lstat(resolve(workspacePath, '.gitmodules'));
279
+ if (!modulesFile.isFile() || modulesFile.isSymbolicLink())
280
+ return unavailable('INVALID');
281
+ }
282
+ catch (error) {
283
+ return unavailable(error.code === 'ENOENT' ? 'MISSING' : 'INVALID');
284
+ }
285
+ const mappings = await runReadOnlyGitAllowFailure(workspacePath, ['config', '--null', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.(path|url)$'], { outputLimit: GIT_LARGE_OUTPUT_LIMIT });
286
+ if (mappings.truncated || (mappings.code !== 0 && mappings.code !== 1))
287
+ return unavailable('INVALID', mappings.code);
288
+ const byName = new Map();
289
+ for (const entry of mappings.text.split('\0')) {
290
+ const separator = entry.indexOf('\n');
291
+ if (separator < 0)
292
+ continue;
293
+ const key = entry.slice(0, separator);
294
+ const value = entry.slice(separator + 1);
295
+ const match = /^submodule\.(.*)\.(path|url)$/.exec(key);
296
+ if (match?.[1] === undefined)
297
+ continue;
298
+ const mapping = byName.get(match[1]) ?? {};
299
+ if (match[2] === 'path')
300
+ mapping.path = value;
301
+ else
302
+ mapping.hasUrl = value.trim().length > 0;
303
+ byName.set(match[1], mapping);
304
+ }
305
+ const mappedPaths = new Set([...byName.values()].flatMap((mapping) => mapping.path !== undefined && mapping.hasUrl ? [mapping.path] : []));
306
+ return paths.map((path) => ({ path, mapping: mappedPaths.has(path) ? 'MAPPED' : 'MISSING' }));
307
+ }
308
+ export async function resolveGitRepository(workspacePath, repositoryPath, initializedSubmodulePaths) {
309
+ const root = await realpath(workspacePath);
310
+ if (repositoryPath.length === 0)
311
+ return root;
312
+ if (repositoryPath.includes('\0') ||
313
+ repositoryPath.startsWith('/') ||
314
+ repositoryPath.startsWith('\\') ||
315
+ repositoryPath
316
+ .split('/')
317
+ .some((segment) => segment.length === 0 || segment === '.' || segment === '..'))
318
+ throw new Error('GIT_REPOSITORY_INVALID');
319
+ try {
320
+ const requestedRepository = resolve(root, repositoryPath);
321
+ const repository = await realpath(requestedRepository);
322
+ if (normalizeWorkspacePath(requestedRepository) !== normalizeWorkspacePath(repository) ||
323
+ !isWithinAllowedRoot(repository, [root]))
324
+ throw new Error('invalid');
325
+ if (initializedSubmodulePaths !== undefined) {
326
+ if (!initializedSubmodulePaths.includes(repositoryPath))
327
+ throw new Error('invalid');
328
+ return repository;
329
+ }
330
+ await assertGitSuperprojectChain(root, repository);
331
+ return repository;
332
+ }
333
+ catch {
334
+ throw new Error('GIT_REPOSITORY_INVALID');
335
+ }
336
+ }
337
+ export async function resolveGitRepositoryTarget(workspacePath, repositoryPath, worktreePath, initializedSubmodulePaths) {
338
+ if (worktreePath === undefined)
339
+ return resolveGitRepository(workspacePath, repositoryPath, initializedSubmodulePaths);
340
+ if (repositoryPath.length !== 0 ||
341
+ worktreePath.length === 0 ||
342
+ worktreePath.length > 4096 ||
343
+ worktreePath.includes('\0'))
344
+ throw new Error('GIT_REPOSITORY_INVALID');
345
+ const requestedKey = normalizeWorkspacePath(worktreePath);
346
+ const worktrees = await listGitWorktrees(workspacePath);
347
+ const matched = worktrees.entries.find((entry) => normalizeWorkspacePath(entry.path) === requestedKey);
348
+ if (matched === undefined || matched.prunable)
349
+ throw new Error('GIT_REPOSITORY_INVALID');
350
+ try {
351
+ return await realpath(matched.path);
352
+ }
353
+ catch {
354
+ throw new Error('GIT_REPOSITORY_INVALID');
355
+ }
356
+ }
357
+ async function assertGitSuperprojectChain(root, repository) {
358
+ const rootKey = normalizeWorkspacePath(root);
359
+ let current = repository;
360
+ const visited = new Set();
361
+ for (let depth = 0; depth < 64; depth += 1) {
362
+ const currentKey = normalizeWorkspacePath(current);
363
+ if (currentKey === rootKey)
364
+ return;
365
+ if (visited.has(currentKey))
366
+ throw new Error('invalid');
367
+ visited.add(currentKey);
368
+ const result = await runReadOnlyGit(current, [
369
+ 'rev-parse',
370
+ '--show-toplevel',
371
+ '--show-superproject-working-tree'
372
+ ]);
373
+ const [topLevel, superproject] = result.text.split(/\r?\n/);
374
+ if (topLevel === undefined || superproject === undefined || superproject.length === 0)
375
+ throw new Error('invalid');
376
+ const resolvedTopLevel = await realpath(topLevel);
377
+ const resolvedSuperproject = await realpath(superproject);
378
+ if (normalizeWorkspacePath(resolvedTopLevel) !== currentKey ||
379
+ normalizeWorkspacePath(resolvedSuperproject) === currentKey ||
380
+ !isWithinAllowedRoot(resolvedSuperproject, [root]) ||
381
+ !isWithinAllowedRoot(current, [resolvedSuperproject]))
382
+ throw new Error('invalid');
383
+ current = resolvedSuperproject;
384
+ }
385
+ throw new Error('invalid');
386
+ }
387
+ export async function listGitHistoryRefs(repository) {
388
+ const result = await runReadOnlyGit(repository, [
389
+ 'for-each-ref',
390
+ '--format=%(refname)%09%(refname:short)%09%(objectname)%09%(HEAD)%09%(symref)',
391
+ 'refs/heads',
392
+ 'refs/remotes'
393
+ ], {
394
+ timeoutMs: GIT_HISTORY_TIMEOUT_MS,
395
+ outputLimit: GIT_LARGE_OUTPUT_LIMIT
396
+ });
397
+ return result.text
398
+ .split(/\r?\n/)
399
+ .flatMap((line) => {
400
+ const [name, displayName, hash, head, symref] = line.split('\t');
401
+ if (name === undefined ||
402
+ displayName === undefined ||
403
+ hash === undefined ||
404
+ symref === undefined ||
405
+ symref.length > 0 ||
406
+ !/^[0-9a-f]{40,64}$/.test(hash))
407
+ return [];
408
+ const kind = name.startsWith('refs/heads/')
409
+ ? 'LOCAL'
410
+ : name.startsWith('refs/remotes/')
411
+ ? 'REMOTE'
412
+ : undefined;
413
+ return kind === undefined ? [] : [{ name, displayName, kind, hash, current: head === '*' }];
414
+ })
415
+ .sort((left, right) => left.kind.localeCompare(right.kind) || left.displayName.localeCompare(right.displayName));
416
+ }
417
+ function selectedHistoryRefs(available, requested) {
418
+ if (requested === undefined || requested.length === 0)
419
+ return available;
420
+ const unique = [...new Set(requested)];
421
+ const selected = unique.map((name) => available.find((entry) => entry.name === name));
422
+ if (selected.some((entry) => entry === undefined))
423
+ throw new Error('GIT_HISTORY_REF_INVALID');
424
+ return selected;
425
+ }
426
+ function historyCursor(offset, fingerprint) {
427
+ return Buffer.from(JSON.stringify({ offset, fingerprint })).toString('base64url');
428
+ }
429
+ function parseHistoryCursor(value, fingerprint) {
430
+ if (value === undefined)
431
+ return 0;
432
+ try {
433
+ const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
434
+ if (!Number.isInteger(parsed.offset) ||
435
+ parsed.offset < 0 ||
436
+ parsed.fingerprint !== fingerprint)
437
+ throw new Error('invalid');
438
+ return parsed.offset;
439
+ }
440
+ catch {
441
+ throw new Error('GIT_HISTORY_CURSOR_INVALID');
442
+ }
443
+ }
444
+ export async function readGitHistory(repository, input) {
445
+ const available = await listGitHistoryRefs(repository);
446
+ const selected = selectedHistoryRefs(available, input.refs);
447
+ if (selected.length === 0)
448
+ return { commits: [], nextCursor: null };
449
+ const limit = input.limit ?? 50;
450
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
451
+ throw new Error('GIT_HISTORY_LIMIT_INVALID');
452
+ const names = selected.map((entry) => entry.name).sort();
453
+ const allBranchesSelected = input.refs === undefined ||
454
+ input.refs.length === 0 ||
455
+ (selected.length === available.length &&
456
+ selected.every((entry) => available.some((candidate) => candidate.name === entry.name)));
457
+ const revisionArguments = allBranchesSelected ? ['--branches', '--remotes'] : names;
458
+ // Bind the offset cursor to the exact branch tips. Advancing a branch invalidates
459
+ // the cursor instead of silently skipping or duplicating commits between pages.
460
+ const fingerprint = createHash('sha256')
461
+ .update(selected
462
+ .map((entry) => `${entry.name}\0${entry.hash}`)
463
+ .sort()
464
+ .join('\0'))
465
+ .digest('hex');
466
+ const offset = parseHistoryCursor(input.cursor, fingerprint);
467
+ const result = await runReadOnlyGit(repository, [
468
+ 'log',
469
+ '--topo-order',
470
+ '--date-order',
471
+ `--skip=${offset}`,
472
+ `--max-count=${limit + 1}`,
473
+ '-z',
474
+ '--format=%H%x00%P%x00%an%x00%ae%x00%ct%x00%s',
475
+ ...revisionArguments,
476
+ '--'
477
+ ], { timeoutMs: GIT_HISTORY_TIMEOUT_MS });
478
+ const refsByHash = new Map();
479
+ for (const entry of available) {
480
+ const refs = refsByHash.get(entry.hash) ?? [];
481
+ refs.push(entry.displayName);
482
+ refsByHash.set(entry.hash, refs);
483
+ }
484
+ const fields = result.text.split('\0');
485
+ const commits = [];
486
+ for (let index = 0; index + 5 < fields.length; index += 6) {
487
+ const [hash, parents = '', authorName = '', authorEmail = '', committedAt = '0', subject = ''] = fields.slice(index, index + 6);
488
+ if (hash === undefined || !/^[0-9a-f]{40,64}$/.test(hash))
489
+ continue;
490
+ commits.push({
491
+ hash,
492
+ parents: parents.length === 0 ? [] : parents.split(' '),
493
+ authorName,
494
+ authorEmail,
495
+ committedAt: Number(committedAt) * 1000,
496
+ subject,
497
+ refs: refsByHash.get(hash) ?? []
498
+ });
499
+ }
500
+ const hasMore = commits.length > limit;
501
+ return {
502
+ commits: commits.slice(0, limit),
503
+ nextCursor: hasMore ? historyCursor(offset + limit, fingerprint) : null
504
+ };
505
+ }
506
+ async function assertHistoryCommitReachable(repository, commit, refs) {
507
+ if (!/^[0-9a-f]{40,64}$/.test(commit))
508
+ throw new Error('GIT_HISTORY_COMMIT_INVALID');
509
+ const selected = selectedHistoryRefs(await listGitHistoryRefs(repository), refs);
510
+ for (const ref of selected) {
511
+ const result = await runReadOnlyGitAllowFailure(repository, [
512
+ 'merge-base',
513
+ '--is-ancestor',
514
+ commit,
515
+ ref.name
516
+ ]);
517
+ if (result.code === 0)
518
+ return;
519
+ }
520
+ throw new Error('GIT_HISTORY_COMMIT_INVALID');
521
+ }
522
+ export async function readGitHistoryFiles(repository, commit, refs) {
523
+ await assertHistoryCommitReachable(repository, commit, refs);
524
+ const result = await runReadOnlyGit(repository, [
525
+ 'diff-tree',
526
+ '--root',
527
+ '--first-parent',
528
+ '--no-commit-id',
529
+ '-r',
530
+ '-z',
531
+ '-M',
532
+ '-C',
533
+ '--name-status',
534
+ commit
535
+ ], {
536
+ timeoutMs: GIT_STATUS_TIMEOUT_MS,
537
+ outputLimit: GIT_LARGE_OUTPUT_LIMIT
538
+ });
539
+ const records = result.text.split('\0');
540
+ const files = [];
541
+ for (let index = 0; index < records.length; index += 1) {
542
+ const status = records[index];
543
+ if (status === undefined || status.length === 0)
544
+ continue;
545
+ const code = status[0] ?? 'M';
546
+ if (code === 'R' || code === 'C') {
547
+ const previousPath = records[index + 1];
548
+ const path = records[index + 2];
549
+ if (previousPath !== undefined && path !== undefined)
550
+ files.push({ path, previousPath, change: gitChange(code) });
551
+ index += 2;
552
+ }
553
+ else {
554
+ const path = records[index + 1];
555
+ if (path !== undefined)
556
+ files.push({ path, change: gitChange(code) });
557
+ index += 1;
558
+ }
559
+ }
560
+ return files;
561
+ }
562
+ export async function readGitHistoryFileDiff(repository, commit, requestedPath, refs, requestedPreviousPath) {
563
+ await assertHistoryCommitReachable(repository, commit, refs);
564
+ const path = safeGitWorkspacePath(requestedPath);
565
+ const previousPath = requestedPreviousPath === undefined ? undefined : safeGitWorkspacePath(requestedPreviousPath);
566
+ const result = await runReadOnlyGit(repository, [
567
+ 'show',
568
+ '--format=',
569
+ '--first-parent',
570
+ '--no-ext-diff',
571
+ '--no-textconv',
572
+ commit,
573
+ '--',
574
+ `:(literal)${path}`,
575
+ ...(previousPath === undefined || previousPath === path ? [] : [`:(literal)${previousPath}`])
576
+ ]);
577
+ const binary = isBinaryGitPatch(result.text);
578
+ if (binary) {
579
+ return {
580
+ binary: true,
581
+ summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
582
+ truncated: result.truncated
583
+ };
584
+ }
585
+ return { binary: false, text: result.text, truncated: result.truncated };
586
+ }
587
+ export async function readGitHistoryFileContentRange(repository, commit, requestedPath, offset, limit, refs) {
588
+ if (!Number.isSafeInteger(offset) ||
589
+ offset < 0 ||
590
+ !Number.isSafeInteger(limit) ||
591
+ limit < 1 ||
592
+ limit > 20 * 1024 * 1024 ||
593
+ offset + limit > 20 * 1024 * 1024)
594
+ throw new Error('FILE_RANGE_INVALID');
595
+ await assertHistoryCommitReachable(repository, commit, refs);
596
+ const path = safeGitWorkspacePath(requestedPath);
597
+ const object = `${commit}:${path}`;
598
+ const sizeResult = await runReadOnlyGit(repository, ['cat-file', '-s', object]);
599
+ const size = Number(sizeResult.text.trim());
600
+ if (!Number.isSafeInteger(size) || size < 0 || offset > size)
601
+ throw new Error('FILE_RANGE_INVALID');
602
+ if (offset === size)
603
+ return { path, size, offset, contentBase64: '', nextOffset: null };
604
+ const result = await runReadOnlyGitAllowFailure(repository, ['cat-file', 'blob', object], {
605
+ outputLimit: offset + limit,
606
+ timeoutMs: GIT_HISTORY_TIMEOUT_MS
607
+ });
608
+ if (result.code !== 0)
609
+ throw gitCommandFailure(result.code, result.stderr, 'cat-file');
610
+ const content = result.output.subarray(offset, Math.min(size, offset + limit));
611
+ const nextOffset = offset + content.length;
612
+ return {
613
+ path,
614
+ size,
615
+ offset,
616
+ contentBase64: content.toString('base64'),
617
+ nextOffset: nextOffset < size ? nextOffset : null
618
+ };
619
+ }
620
+ export async function openGitHistoryFileContent(repository, commit, requestedPath, refs) {
621
+ await assertHistoryCommitReachable(repository, commit, refs);
622
+ const path = safeGitWorkspacePath(requestedPath);
623
+ const object = `${commit}:${path}`;
624
+ const sizeResult = await runReadOnlyGit(repository, ['cat-file', '-s', object]);
625
+ const size = Number(sizeResult.text.trim());
626
+ if (!Number.isSafeInteger(size) || size < 0)
627
+ throw new Error('FILE_RANGE_INVALID');
628
+ return {
629
+ path,
630
+ size,
631
+ chunks: (async function* () {
632
+ const child = spawn('git', ['cat-file', 'blob', object], {
633
+ cwd: repository,
634
+ shell: false,
635
+ windowsHide: true,
636
+ stdio: ['ignore', 'pipe', 'pipe'],
637
+ env: {
638
+ ...process.env,
639
+ ...gitOperationEnvironment.getStore(),
640
+ GIT_OPTIONAL_LOCKS: '0'
641
+ }
642
+ });
643
+ const errors = [];
644
+ let errorSize = 0;
645
+ let closed = false;
646
+ const exit = new Promise((resolveExit) => {
647
+ child.stderr.on('data', (chunk) => {
648
+ const remaining = Math.max(0, 4096 - errorSize);
649
+ if (remaining === 0)
650
+ return;
651
+ const retained = chunk.subarray(0, remaining);
652
+ errors.push(retained);
653
+ errorSize += retained.length;
654
+ });
655
+ child.once('error', (error) => {
656
+ closed = true;
657
+ resolveExit({ code: 1, stderr: error.message });
658
+ });
659
+ child.once('close', (code) => {
660
+ closed = true;
661
+ resolveExit({
662
+ code: code ?? 1,
663
+ stderr: Buffer.concat(errors).toString('utf8')
664
+ });
665
+ });
666
+ });
667
+ try {
668
+ for await (const chunk of child.stdout)
669
+ yield new Uint8Array(chunk);
670
+ const result = await exit;
671
+ if (result.code !== 0)
672
+ throw gitCommandFailure(result.code, result.stderr, 'cat-file');
673
+ }
674
+ finally {
675
+ if (!closed)
676
+ child.kill('SIGKILL');
677
+ }
678
+ })()
679
+ };
680
+ }
681
+ export async function readCurrentChanges(workspacePath) {
682
+ return (await readCurrentChangesSummary(workspacePath)).changes;
683
+ }
684
+ export async function readCurrentChangeDiff(workspacePath, requestedPath) {
685
+ const path = safeGitWorkspacePath(requestedPath);
686
+ await assertCurrentChangePathAccessible(workspacePath, path);
687
+ const result = await readCurrentChangeGitDiff(workspacePath, path);
688
+ const after = await readWorkingTreeText(workspacePath, path);
689
+ const binary = isBinaryGitPatch(result.text);
690
+ if (binary) {
691
+ return {
692
+ binary: true,
693
+ summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
694
+ truncated: result.truncated || after?.truncated === true
695
+ };
696
+ }
697
+ return {
698
+ binary: false,
699
+ text: result.text,
700
+ truncated: result.truncated || after?.truncated === true,
701
+ // A non-UTF-8 worktree must not override Git's text patch or supply lossy context.
702
+ ...(after?.binary === true ? {} : { afterText: after?.text ?? '' }),
703
+ ...(after === undefined || after.binary ? {} : { afterTruncated: after.truncated })
704
+ };
705
+ }
706
+ async function readCurrentChangeGitDiff(workspacePath, path) {
707
+ const untracked = await runReadOnlyGit(workspacePath, [
708
+ 'ls-files',
709
+ '--others',
710
+ '--exclude-standard',
711
+ '-z',
712
+ '--',
713
+ `:(literal)${path}`
714
+ ]);
715
+ if (untracked.text.split('\0').includes(path)) {
716
+ const result = await runReadOnlyGitAllowFailure(workspacePath, [
717
+ 'diff',
718
+ '--no-index',
719
+ '--no-ext-diff',
720
+ '--no-textconv',
721
+ '--',
722
+ '/dev/null',
723
+ path
724
+ ]);
725
+ if (result.code !== 0 && result.code !== 1)
726
+ throw gitCommandFailure(result.code, result.stderr, 'diff');
727
+ return result;
728
+ }
729
+ return runReadOnlyGit(workspacePath, [
730
+ 'diff',
731
+ '--no-ext-diff',
732
+ '--no-textconv',
733
+ 'HEAD',
734
+ '--',
735
+ `:(literal)${path}`
736
+ ]);
737
+ }
738
+ /** Current Changes must not expose an external link. */
739
+ export async function isWorkspaceChangeVisible(workspacePath, requestedPath) {
740
+ try {
741
+ await assertCurrentChangePathAccessible(workspacePath, safeGitWorkspacePath(requestedPath));
742
+ return true;
743
+ }
744
+ catch (error) {
745
+ if (error instanceof Error &&
746
+ (error.message === 'FILE_PATH_ESCAPE' ||
747
+ error.message === 'FILE_NOT_FOUND' ||
748
+ error.message === 'FILE_NOT_REGULAR'))
749
+ return false;
750
+ throw error;
751
+ }
752
+ }
753
+ async function assertCurrentChangePathAccessible(workspacePath, path) {
754
+ const root = await realpath(workspacePath);
755
+ const candidate = resolve(root, path);
756
+ const metadata = await lstat(candidate).catch((error) => {
757
+ if (error.code === 'ENOENT')
758
+ return undefined;
759
+ throw error;
760
+ });
761
+ if (metadata === undefined)
762
+ return;
763
+ await resolveWorkspaceEntry(root, path, false);
764
+ }
765
+ function isBinaryGitPatch(text) {
766
+ // Git lines use LF; Unicode line separators inside hunk content are not marker boundaries.
767
+ return /(?:^|\n)Binary files [^\n]+ differ(?:\r?\n|$)/u.test(text);
768
+ }
769
+ async function readWorkingTreeText(workspacePath, path) {
770
+ const root = await realpath(workspacePath);
771
+ let file;
772
+ try {
773
+ file = await resolveWorkspaceEntry(root, path, false);
774
+ }
775
+ catch (error) {
776
+ if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
777
+ return undefined;
778
+ throw error;
779
+ }
780
+ const metadata = await lstat(file);
781
+ if (!metadata.isFile())
782
+ return undefined;
783
+ const source = await readFile(file);
784
+ return diffTextFromBuffer(source, source.length > GIT_OUTPUT_LIMIT);
785
+ }
786
+ function diffTextFromBuffer(source, truncated) {
787
+ if (source.includes(0) || !isValidUtf8(source))
788
+ return { binary: true, truncated };
789
+ let end = Math.min(source.length, GIT_OUTPUT_LIMIT);
790
+ while (end > 0 && end < source.length && isUtf8ContinuationByte(source[end]))
791
+ end -= 1;
792
+ return { binary: false, text: source.subarray(0, end).toString('utf8'), truncated };
793
+ }
794
+ export async function restoreCurrentChange(workspacePath, requestedPath, state) {
795
+ const path = safeGitWorkspacePath(requestedPath);
796
+ const pathspec = `:(literal)${path}`;
797
+ if (state === 'UNTRACKED') {
798
+ const status = await runReadOnlyGit(workspacePath, [
799
+ 'status',
800
+ '--porcelain=v1',
801
+ '-z',
802
+ '--untracked-files=all',
803
+ '--',
804
+ pathspec
805
+ ]);
806
+ if (!status.text
807
+ .split('\0')
808
+ .some((record) => record.startsWith('?? ') && record.slice(3) === path))
809
+ throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
810
+ const root = await realpath(workspacePath);
811
+ const file = resolve(root, path);
812
+ const metadata = await lstat(file);
813
+ if (!metadata.isFile() || metadata.isSymbolicLink())
814
+ throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
815
+ await unlink(file);
816
+ return;
817
+ }
818
+ if (state === 'UNSTAGED') {
819
+ await runGit(workspacePath, ['restore', '--worktree', '--', pathspec]);
820
+ return;
821
+ }
822
+ await runGit(workspacePath, [
823
+ 'restore',
824
+ '--source=HEAD',
825
+ '--staged',
826
+ '--worktree',
827
+ '--',
828
+ pathspec
829
+ ]);
830
+ }
831
+ function safeGitWorkspacePath(requestedPath) {
832
+ if (requestedPath.length === 0 ||
833
+ requestedPath.includes('\0') ||
834
+ requestedPath.startsWith('/') ||
835
+ requestedPath.startsWith('\\'))
836
+ throw new Error('FILE_PATH_ESCAPE');
837
+ const normalized = requestedPath.replaceAll('\\', '/');
838
+ if (normalized
839
+ .split('/')
840
+ .some((segment) => segment === '' || segment === '..' || segment === '.git'))
841
+ throw new Error('FILE_PATH_ESCAPE');
842
+ return normalized;
843
+ }
844
+ function gitChange(status) {
845
+ if (status === 'A')
846
+ return 'ADDED';
847
+ if (status === 'D')
848
+ return 'DELETED';
849
+ if (status === 'R')
850
+ return 'RENAMED';
851
+ if (status === 'C')
852
+ return 'COPIED';
853
+ if (status === 'T')
854
+ return 'TYPE_CHANGED';
855
+ return 'MODIFIED';
856
+ }
857
+ export class GitCommandError extends Error {
858
+ code;
859
+ stderr;
860
+ publicDetails;
861
+ publicParameters;
862
+ constructor(code, stderr) {
863
+ super('GIT_COMMAND_FAILED');
864
+ this.code = code;
865
+ this.stderr = stderr;
866
+ this.publicParameters = { gitExitCode: code };
867
+ this.publicDetails = stderr.trim().slice(0, 1_000) || `Git exited with status ${code}.`;
868
+ }
869
+ }
870
+ function gitCommandFailure(code, stderr, subcommand, durationMs, reason) {
871
+ logNodeCommandFailure(`git ${subcommand}`, {
872
+ exitCode: code,
873
+ stderr,
874
+ ...(durationMs === undefined ? {} : { durationMs }),
875
+ ...(reason === undefined ? {} : { reason })
876
+ });
877
+ return new GitCommandError(code, stderr);
878
+ }
879
+ async function runReadOnlyGit(workspacePath, args, options = {}) {
880
+ return runGit(workspacePath, args, { ...options, readOnly: true });
881
+ }
882
+ async function runReadOnlyGitAllowFailure(workspacePath, args, options = {}) {
883
+ return runGitAllowFailure(workspacePath, args, { ...options, readOnly: true });
884
+ }
885
+ async function runGit(workspacePath, args, options = {}) {
886
+ const startedAt = performance.now();
887
+ const result = await runGitAllowFailure(workspacePath, args, options);
888
+ if (result.code !== 0) {
889
+ throw gitCommandFailure(result.code, result.stderr, args[0] ?? 'unknown', Math.round(performance.now() - startedAt));
890
+ }
891
+ return result;
892
+ }
893
+ async function runGitAllowFailure(workspacePath, args, options = {}) {
894
+ return new Promise((resolve, reject) => {
895
+ const startedAt = performance.now();
896
+ const outputLimit = options.outputLimit ?? GIT_OUTPUT_LIMIT;
897
+ const child = spawn('git', [...args], {
898
+ cwd: workspacePath,
899
+ shell: false,
900
+ windowsHide: true,
901
+ stdio: ['ignore', 'pipe', 'pipe'],
902
+ env: {
903
+ ...process.env,
904
+ ...gitOperationEnvironment.getStore(),
905
+ ...(options.readOnly === true ? { GIT_OPTIONAL_LOCKS: '0' } : {})
906
+ }
907
+ });
908
+ const chunks = [];
909
+ const errors = [];
910
+ let size = 0;
911
+ let errorSize = 0;
912
+ let truncated = false;
913
+ let timedOut = false;
914
+ const timer = setTimeout(() => {
915
+ timedOut = true;
916
+ if (process.platform === 'win32') {
917
+ const pid = child.pid;
918
+ if (pid === undefined) {
919
+ child.kill('SIGKILL');
920
+ return;
921
+ }
922
+ void terminateProcessTree({
923
+ pid,
924
+ kill: (signal) => child.kill(signal)
925
+ }, 'win32').catch(() => {
926
+ try {
927
+ child.kill('SIGKILL');
928
+ }
929
+ catch {
930
+ // The process may have exited while taskkill was starting.
931
+ }
932
+ });
933
+ return;
934
+ }
935
+ child.kill('SIGKILL');
936
+ }, options.timeoutMs ?? GIT_TIMEOUT_MS);
937
+ child.stdout.on('data', (chunk) => {
938
+ const remaining = Math.max(0, outputLimit - size);
939
+ if (remaining > 0) {
940
+ const retained = chunk.subarray(0, remaining);
941
+ chunks.push(retained);
942
+ size += retained.length;
943
+ }
944
+ truncated ||= chunk.length > remaining;
945
+ });
946
+ child.stderr.on('data', (chunk) => {
947
+ const remaining = Math.max(0, 4096 - errorSize);
948
+ if (remaining === 0)
949
+ return;
950
+ const retained = chunk.subarray(0, remaining);
951
+ errors.push(retained);
952
+ errorSize += retained.length;
953
+ });
954
+ child.once('error', (error) => {
955
+ clearTimeout(timer);
956
+ logNodeCommandFailure(`git ${args[0] ?? 'unknown'}`, {
957
+ reason: error.code ?? 'START_FAILED'
958
+ });
959
+ reject(error);
960
+ });
961
+ child.once('close', (code) => {
962
+ clearTimeout(timer);
963
+ const stderr = Buffer.concat(errors).toString('utf8');
964
+ if (timedOut) {
965
+ reject(gitCommandFailure(code === null || code === 0 ? 1 : code, `Git command timed out after ${options.timeoutMs ?? GIT_TIMEOUT_MS} ms.${stderr ? `\n${stderr}` : ''}`, args[0] ?? 'unknown', Math.round(performance.now() - startedAt), 'TIMEOUT'));
966
+ return;
967
+ }
968
+ const output = Buffer.concat(chunks);
969
+ resolve({
970
+ code: code ?? 1,
971
+ output,
972
+ text: output.toString('utf8'),
973
+ stderr,
974
+ truncated
975
+ });
976
+ });
977
+ });
978
+ }
979
+ function canRetryGitWorktreeListWithoutNullOutput(result) {
980
+ return (result.code === 129 ||
981
+ /(?:unknown|unrecognized|invalid)\s+(?:option|switch).*['"`]?-?z\b/iu.test(result.stderr));
982
+ }
983
+ function isGitUnavailable(error) {
984
+ return (error instanceof Error && 'code' in error && error.code === 'ENOENT');
985
+ }
986
+ function summarizeText(value) {
987
+ return JSON.stringify({
988
+ sha256: createHash('sha256').update(value).digest('hex'),
989
+ bytes: Buffer.byteLength(value)
990
+ });
991
+ }