@withtruing/companion 0.13.3 → 0.14.1

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 (56) hide show
  1. package/README.md +24 -25
  2. package/dist/anthropic-output-schema.js +41 -0
  3. package/dist/api-key-caller.js +9 -2
  4. package/dist/claude-cli-caller.js +107 -8
  5. package/dist/cli.js +833 -206
  6. package/dist/codex-cli-caller.js +85 -14
  7. package/dist/config.js +177 -2
  8. package/dist/git-range.js +88 -46
  9. package/dist/http-client.js +107 -10
  10. package/dist/investigation-reader.js +12 -6
  11. package/dist/managed-repo.js +132 -0
  12. package/dist/observed-sources.js +132 -6
  13. package/dist/reader-result-contract.js +296 -2
  14. package/dist/repository-provider.js +608 -0
  15. package/dist/schema-entities.js +18 -18
  16. package/dist/service.js +7 -2
  17. package/dist/surface-mapper.js +53 -11
  18. package/dist/text-model.js +91 -8
  19. package/dist/whole-read/archive.js +196 -0
  20. package/dist/whole-read/checks.js +297 -0
  21. package/dist/whole-read/claims.js +196 -0
  22. package/dist/whole-read/coverage-ledger.js +51 -0
  23. package/dist/whole-read/decode.js +234 -0
  24. package/dist/whole-read/drafting.js +502 -0
  25. package/dist/whole-read/generated-rules.js +68 -0
  26. package/dist/whole-read/go-proof.js +255 -0
  27. package/dist/whole-read/grouping-prompt.js +19 -0
  28. package/dist/whole-read/grouping.js +247 -0
  29. package/dist/whole-read/identity.js +103 -0
  30. package/dist/whole-read/import-graph.js +293 -0
  31. package/dist/whole-read/inventory-collector.js +55 -0
  32. package/dist/whole-read/inventory-rules.js +141 -0
  33. package/dist/whole-read/lockfiles.js +947 -0
  34. package/dist/whole-read/notes.js +216 -0
  35. package/dist/whole-read/npm-proof.js +272 -0
  36. package/dist/whole-read/owner-decision-detectors.js +270 -0
  37. package/dist/whole-read/packing.js +612 -0
  38. package/dist/whole-read/pages.js +429 -0
  39. package/dist/whole-read/phase-policy.js +54 -0
  40. package/dist/whole-read/prd-build.js +279 -0
  41. package/dist/whole-read/prd-fidelity.js +14 -0
  42. package/dist/whole-read/python-proof.js +489 -0
  43. package/dist/whole-read/read-prompt.js +67 -0
  44. package/dist/whole-read/redaction.js +411 -0
  45. package/dist/whole-read/relationship-mapping.js +98 -0
  46. package/dist/whole-read/sanitize.js +266 -0
  47. package/dist/whole-read/secret-rules.js +26 -0
  48. package/dist/whole-read/structure.js +300 -0
  49. package/dist/whole-read/types.js +2 -0
  50. package/dist/whole-read-claims.js +12 -0
  51. package/dist/whole-read-git.js +340 -0
  52. package/dist/whole-read-pool.js +80 -0
  53. package/dist/whole-read-runner.js +945 -0
  54. package/dist/whole-read-shared-source.js +194 -0
  55. package/dist/whole-read-temp.js +479 -0
  56. package/package.json +8 -2
@@ -12,6 +12,8 @@ import { assertSingleLineArguments } from './cli-arguments.js';
12
12
  import { resolveCodexCommand } from './codex-binary.js';
13
13
  import { gitErrorMessage } from './sandbox.js';
14
14
  import { stripIntroducedNulls, toCodexStrictSchema } from './strict-schema.js';
15
+ import { WholeReadTransportError, } from './text-model.js';
16
+ import { isUsageLimitError, parseUsageLimitReset } from './usage-limit.js';
15
17
  const WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000;
16
18
  const STDOUT_SAMPLE_CHARS = 200;
17
19
  const UNPARSEABLE_LINE_SAMPLE_CHARS = 120;
@@ -78,10 +80,19 @@ function terminateChild(child) {
78
80
  if (process.platform === 'win32' && child.pid) {
79
81
  const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
80
82
  killer.on('error', () => child.kill());
83
+ killer.on('close', (code) => { if (code !== 0)
84
+ child.kill(); });
81
85
  return;
82
86
  }
83
87
  child.kill();
84
88
  }
89
+ async function terminateChildAndWait(child) {
90
+ if (child.exitCode !== null || child.signalCode !== null)
91
+ return;
92
+ const closed = new Promise((resolvePromise) => child.once('close', () => resolvePromise()));
93
+ terminateChild(child);
94
+ await closed;
95
+ }
85
96
  function isWithin(base, candidate) {
86
97
  const relation = relative(resolve(base), resolve(candidate));
87
98
  return relation === '' || (!relation.startsWith('..') && !isAbsolute(relation));
@@ -240,6 +251,19 @@ function namedCodexFailure(message, codexHome) {
240
251
  }
241
252
  return null;
242
253
  }
254
+ function safeCodexFailure(message) {
255
+ if (isUsageLimitError(message)) {
256
+ const reset = parseUsageLimitReset(message);
257
+ return new WholeReadTransportError('usage_limit', 'codex', reset?.toISOString());
258
+ }
259
+ if (/billing|credit|payment required/i.test(message)) {
260
+ return new WholeReadTransportError('billing_exhausted', 'codex');
261
+ }
262
+ if (/rate_limit_error|http 429|too many requests|overloaded/i.test(message)) {
263
+ return new WholeReadTransportError('provider_rate_limit', 'codex');
264
+ }
265
+ return new WholeReadTransportError('capability_failure', 'codex');
266
+ }
243
267
  function usageFrom(value) {
244
268
  if (!value || typeof value !== 'object' || Array.isArray(value))
245
269
  return null;
@@ -253,7 +277,7 @@ function usageFrom(value) {
253
277
  function toolUseAttempt(type) {
254
278
  return new Error(`CodexToolUseAttempted: Codex attempted a tool (${type}) in a no-tools call; the result was discarded.`);
255
279
  }
256
- export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath) {
280
+ export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath, tempRoot = tmpdir()) {
257
281
  const args = [
258
282
  'exec',
259
283
  '--json',
@@ -285,7 +309,7 @@ export function buildArgs(request, options, cwd, outputPath, schemaPath, instruc
285
309
  '-',
286
310
  ];
287
311
  assertSingleLineArguments('codex', args);
288
- assertSafeInvocation(args);
312
+ assertSafeInvocation(args, tempRoot);
289
313
  return args;
290
314
  }
291
315
  function diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, purpose) {
@@ -315,10 +339,11 @@ removeDirectory = rm) {
315
339
  }
316
340
  assertAllowedModel(options.model);
317
341
  const codexHome = await validatedCodexHome(options.codexHome ?? defaultCodexHome());
318
- const invocationCwd = await mkdtemp(join(tmpdir(), 'product-console-codex-empty-'));
342
+ const tempRoot = request.wholeRead?.attemptDirectory ?? tmpdir();
343
+ const invocationCwd = await mkdtemp(join(tempRoot, 'product-console-codex-empty-'));
319
344
  let callDir = null;
320
345
  try {
321
- callDir = await mkdtemp(join(tmpdir(), 'product-console-codex-call-'));
346
+ callDir = await mkdtemp(join(tempRoot, 'product-console-codex-call-'));
322
347
  const outputPath = join(callDir, 'last-message.txt');
323
348
  const instructionsPath = join(callDir, 'model-instructions.txt');
324
349
  await writeFile(instructionsPath, request.system, { encoding: 'utf8', mode: 0o600 });
@@ -330,13 +355,15 @@ removeDirectory = rm) {
330
355
  schemaPath = join(callDir, 'schema.json');
331
356
  await writeFile(schemaPath, JSON.stringify(strict.schema), { encoding: 'utf8', mode: 0o600 });
332
357
  }
333
- const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath);
358
+ const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath, tempRoot);
334
359
  let stderrTail = '';
335
360
  let timedOut = false;
336
361
  let stdinError = null;
337
362
  let fatalError = null;
338
363
  let turnFailure = '';
339
364
  let usage = null;
365
+ let actualModel = null;
366
+ let finishReason = null;
340
367
  let toleratedErrorItems = 0;
341
368
  let stdoutBytes = 0;
342
369
  let stdoutLines = 0;
@@ -356,7 +383,7 @@ removeDirectory = rm) {
356
383
  });
357
384
  const stdoutDecoder = new StringDecoder('utf8');
358
385
  const recordStdout = (text) => {
359
- if (!text)
386
+ if (!text || request.wholeRead)
360
387
  return;
361
388
  if (stdoutFirst.length < STDOUT_SAMPLE_CHARS) {
362
389
  stdoutFirst += text.slice(0, STDOUT_SAMPLE_CHARS - stdoutFirst.length);
@@ -373,7 +400,8 @@ removeDirectory = rm) {
373
400
  terminateChild(child);
374
401
  });
375
402
  child.stderr.on('data', (chunk) => {
376
- stderrTail = (stderrTail + chunk.toString()).slice(-2000);
403
+ if (!request.wholeRead)
404
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000);
377
405
  });
378
406
  const rl = createInterface({ input: child.stdout });
379
407
  rl.on('line', (line) => {
@@ -384,7 +412,7 @@ removeDirectory = rm) {
384
412
  }
385
413
  catch {
386
414
  unparseableLines++;
387
- if (firstUnparseableLineLength === null) {
415
+ if (!request.wholeRead && firstUnparseableLineLength === null) {
388
416
  firstUnparseableLineLength = line.length;
389
417
  firstUnparseableLineFirst = line.slice(0, UNPARSEABLE_LINE_SAMPLE_CHARS);
390
418
  firstUnparseableLineLast = line.slice(-UNPARSEABLE_LINE_SAMPLE_CHARS);
@@ -403,22 +431,29 @@ removeDirectory = rm) {
403
431
  toleratedErrorItems++;
404
432
  }
405
433
  else if (classified !== 'agent_message' && classified !== 'reasoning') {
406
- fatalError ??= toolUseAttempt(classified);
434
+ fatalError ??= request.wholeRead
435
+ ? new WholeReadTransportError('capability_failure', 'codex')
436
+ : toolUseAttempt(classified);
407
437
  terminateChild(child);
408
438
  }
409
439
  }
410
- if (eventType === 'turn.completed')
440
+ if (eventType === 'turn.completed') {
411
441
  usage = usageFrom(event.usage);
442
+ if (typeof event.model === 'string')
443
+ actualModel = event.model;
444
+ if (typeof event.finish_reason === 'string')
445
+ finishReason = event.finish_reason;
446
+ }
412
447
  if (eventType === 'error' || eventType === 'turn.failed') {
413
448
  const message = eventMessage(event);
414
449
  const named = namedCodexFailure(message, codexHome);
415
450
  if (named) {
416
- fatalError ??= named;
451
+ fatalError ??= request.wholeRead ? safeCodexFailure(message) : named;
417
452
  if (/401 Unauthorized/i.test(message))
418
453
  terminateChild(child);
419
454
  }
420
455
  else if (eventType === 'turn.failed') {
421
- turnFailure = message;
456
+ turnFailure = request.wholeRead ? 'capability_failure' : message;
422
457
  }
423
458
  }
424
459
  });
@@ -430,6 +465,14 @@ removeDirectory = rm) {
430
465
  child.on('error', reject);
431
466
  child.on('close', (code) => resolvePromise(code ?? 1));
432
467
  });
468
+ const unregisterReader = request.wholeRead?.registerReader({
469
+ cancel: () => terminateChildAndWait(child),
470
+ });
471
+ const abort = () => { void terminateChildAndWait(child); };
472
+ if (request.wholeRead?.signal.aborted)
473
+ abort();
474
+ else
475
+ request.wholeRead?.signal.addEventListener('abort', abort, { once: true });
433
476
  try {
434
477
  if (request.stableText)
435
478
  child.stdin.write(`${request.stableText}\n\n`);
@@ -446,6 +489,11 @@ removeDirectory = rm) {
446
489
  }
447
490
  finally {
448
491
  clearTimeout(timer);
492
+ request.wholeRead?.signal.removeEventListener('abort', abort);
493
+ unregisterReader?.();
494
+ }
495
+ if (request.wholeRead?.signal.aborted) {
496
+ throw new WholeReadTransportError('cancelled', 'codex');
449
497
  }
450
498
  options.onDiagnostic?.({ toleratedErrorItems });
451
499
  if (/codex_core::tools::router/i.test(stderrTail) && /(?:spawn|exec_command)/i.test(stderrTail)) {
@@ -465,29 +513,52 @@ removeDirectory = rm) {
465
513
  }
466
514
  const capturedStdinError = stdinError;
467
515
  if (turnFailure) {
516
+ if (request.wholeRead)
517
+ throw new WholeReadTransportError('capability_failure', 'codex');
468
518
  const named = namedCodexFailure(turnFailure, codexHome);
469
519
  throw named ?? new Error(`Codex turn failed: ${gitErrorMessage(turnFailure)}; ${diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose)}`);
470
520
  }
471
521
  if (finalText === null || finalText.length === 0) {
522
+ if (request.wholeRead)
523
+ throw new WholeReadTransportError('capability_failure', 'codex');
472
524
  if (capturedStdinError)
473
525
  throw promptWriteFailure(capturedStdinError);
474
526
  throw new Error(diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose));
475
527
  }
476
- if (capturedStdinError)
528
+ if (capturedStdinError) {
529
+ if (request.wholeRead)
530
+ throw new WholeReadTransportError('capability_failure', 'codex');
477
531
  throw promptWriteFailure(capturedStdinError);
532
+ }
533
+ if (request.wholeRead && finishReason === 'max_tokens') {
534
+ throw new WholeReadTransportError('output_truncated', 'codex');
535
+ }
536
+ if (request.wholeRead && finishReason === 'refusal') {
537
+ throw new WholeReadTransportError('model_declined', 'codex');
538
+ }
478
539
  if (request.schema) {
479
540
  let result;
480
541
  try {
481
542
  result = JSON.parse(finalText);
482
543
  }
483
544
  catch (error) {
545
+ if (request.wholeRead)
546
+ throw new WholeReadTransportError('capability_failure', 'codex');
484
547
  throw new Error(`Codex wrote an invalid JSON final message: ${gitErrorMessage(error)}`);
485
548
  }
486
549
  stripIntroducedNulls(result, schemaRecord);
487
550
  finalText = JSON.stringify(result);
488
551
  }
489
552
  options.onUsage?.(usage);
490
- return { text: finalText, usage };
553
+ return {
554
+ text: finalText,
555
+ usage,
556
+ ...(request.wholeRead ? { metadata: {
557
+ finishReason,
558
+ model: actualModel ?? options.model,
559
+ usageAvailable: usage !== null,
560
+ } } : {}),
561
+ };
491
562
  }
492
563
  finally {
493
564
  await removeTemporaryDirectory(invocationCwd, removeDirectory);
package/dist/config.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /** Reads/writes ~/.product-console-companion/config.json. */
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { configuredManagedRepository, githubLabel, ManagedCopyRetirementError, retireManagedCopy, usesTemporaryManagedStorage, } from './managed-repo.js';
5
6
  // Kept on purpose from the old name so existing pairings continue to load after upgrading.
6
7
  const CONFIG_DIR = join(homedir(), '.product-console-companion');
7
8
  const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
@@ -98,6 +99,180 @@ export function recordWorkspaceId(url, token, workspaceId, configFile) {
98
99
  save(config, configFile);
99
100
  return config;
100
101
  }
102
+ export function hasTemporaryManagedRepository(args) {
103
+ const pairing = loadConfig(args.configFile)?.pairings.find((candidate) => samePairing(candidate, args));
104
+ if (!pairing)
105
+ return false;
106
+ const wanted = githubLabel(args.repository).toLowerCase();
107
+ return pairing.connections.some((connection) => {
108
+ if (connection.managed !== true || connection.repositoryStorage !== 'temporary')
109
+ return false;
110
+ try {
111
+ return githubLabel(configuredManagedRepository(connection)).toLowerCase() === wanted;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ });
117
+ }
118
+ /** Marks legacy managed connections only after this Console enables temporary repositories.
119
+ * The old path is retained as B38-c's cleanup target; no source storage is changed here. */
120
+ export function migrateManagedRepositoriesToTemporaryStorage(args) {
121
+ const config = loadConfig(args.configFile);
122
+ if (!config)
123
+ return null;
124
+ let pairing = config.pairings.find((candidate) => samePairing(candidate, args));
125
+ if (!pairing) {
126
+ const legacy = config.pairings.filter((candidate) => candidate.workspaceId === undefined
127
+ && normalizeConsoleUrl(candidate.url) === normalizeConsoleUrl(args.url));
128
+ if (legacy.length === 1)
129
+ pairing = legacy[0];
130
+ }
131
+ if (!pairing)
132
+ return null;
133
+ let changed = false;
134
+ if (pairing.workspaceId === undefined) {
135
+ pairing.workspaceId = args.workspaceId;
136
+ changed = true;
137
+ }
138
+ for (const connection of pairing.connections) {
139
+ if (connection.managed !== true)
140
+ continue;
141
+ try {
142
+ const repository = configuredManagedRepository(connection);
143
+ const identity = { host: 'github.com', port: null, path: githubLabel(repository) };
144
+ if (connection.repositoryStorage !== 'temporary'
145
+ || connection.repository?.host !== identity.host
146
+ || connection.repository.port !== identity.port
147
+ || connection.repository.path !== identity.path) {
148
+ connection.repository = identity;
149
+ connection.repositoryStorage = 'temporary';
150
+ changed = true;
151
+ }
152
+ }
153
+ catch {
154
+ // An invalid legacy entry is left untouched and cannot be cleaned up as a migrated copy.
155
+ }
156
+ }
157
+ if (changed)
158
+ save(config, args.configFile);
159
+ return pairing;
160
+ }
161
+ function cleanupPathKey(path) {
162
+ let absolute = resolve(path);
163
+ try {
164
+ absolute = realpathSync.native(absolute);
165
+ }
166
+ catch {
167
+ // A missing path has no physical alias and is compared lexically.
168
+ }
169
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
170
+ }
171
+ /** Deletes historical managed copies only while this workspace advertises the rollout and only
172
+ * after the per-connection migration marker is durable. Every configured Console is considered
173
+ * before a shared path can be removed. */
174
+ export async function cleanupMigratedManagedRepositories(args, dependencies = {}) {
175
+ const result = { removed: [], cleanupPending: [] };
176
+ if (!args.temporaryRepositories)
177
+ return result;
178
+ const config = loadConfig(args.configFile);
179
+ const pairing = config?.pairings.find((candidate) => samePairing(candidate, args));
180
+ if (!config || !pairing)
181
+ return result;
182
+ const reposDir = managedReposDir(args.configFile);
183
+ const handled = new Set();
184
+ for (const connection of pairing.connections) {
185
+ if (!usesTemporaryManagedStorage(connection) || connection.managedCopyCleanup?.state === 'removed')
186
+ continue;
187
+ const path = resolve(connection.repoPath);
188
+ const key = cleanupPathKey(path);
189
+ if (handled.has(key))
190
+ continue;
191
+ handled.add(key);
192
+ const references = config.pairings.flatMap((candidate) => candidate.connections)
193
+ .filter((candidate) => cleanupPathKey(candidate.repoPath) === key);
194
+ let repository;
195
+ try {
196
+ repository = configuredManagedRepository(connection);
197
+ }
198
+ catch {
199
+ const cleanupAttempts = connection.managedCopyCleanup?.cleanupAttempts ?? 0;
200
+ connection.managedCopyCleanup = {
201
+ state: 'cleanup_pending', path, cleanupAttempts, errorCode: 'cleanup_identity_mismatch',
202
+ };
203
+ save(config, args.configFile);
204
+ result.cleanupPending.push({ path, cleanupAttempts, errorCode: 'cleanup_identity_mismatch' });
205
+ continue;
206
+ }
207
+ const label = githubLabel(repository).toLowerCase();
208
+ const allReferencesMigrated = references.every((candidate) => {
209
+ if (!usesTemporaryManagedStorage(candidate))
210
+ return false;
211
+ try {
212
+ return githubLabel(configuredManagedRepository(candidate)).toLowerCase() === label;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ });
218
+ if (!allReferencesMigrated) {
219
+ const cleanupAttempts = connection.managedCopyCleanup?.cleanupAttempts ?? 0;
220
+ connection.managedCopyCleanup = {
221
+ state: 'cleanup_pending', path, cleanupAttempts, errorCode: 'cleanup_path_referenced',
222
+ };
223
+ save(config, args.configFile);
224
+ result.cleanupPending.push({ path, cleanupAttempts, errorCode: 'cleanup_path_referenced' });
225
+ continue;
226
+ }
227
+ const migratedReferences = references.filter(usesTemporaryManagedStorage);
228
+ try {
229
+ const retired = await retireManagedCopy({
230
+ repoPath: path,
231
+ reposDir,
232
+ repo: repository,
233
+ onValidated() {
234
+ for (const candidate of migratedReferences) {
235
+ candidate.managedCopyCleanup = {
236
+ state: 'cleanup_pending',
237
+ path,
238
+ cleanupAttempts: candidate.managedCopyCleanup?.cleanupAttempts ?? 0,
239
+ errorCode: null,
240
+ };
241
+ }
242
+ save(config, args.configFile);
243
+ },
244
+ }, dependencies);
245
+ for (const candidate of migratedReferences) {
246
+ candidate.managedCopyCleanup = {
247
+ state: 'removed',
248
+ path,
249
+ cleanupAttempts: (candidate.managedCopyCleanup?.cleanupAttempts ?? 0) + retired.cleanupAttempts,
250
+ errorCode: null,
251
+ };
252
+ }
253
+ save(config, args.configFile);
254
+ result.removed.push(path);
255
+ }
256
+ catch (error) {
257
+ const errorCode = error instanceof ManagedCopyRetirementError
258
+ ? error.code
259
+ : 'cleanup_delete_failed';
260
+ for (const candidate of migratedReferences) {
261
+ candidate.managedCopyCleanup = {
262
+ state: 'cleanup_pending',
263
+ path,
264
+ cleanupAttempts: (candidate.managedCopyCleanup?.cleanupAttempts ?? 0)
265
+ + (errorCode === 'cleanup_delete_failed' ? 5 : 0),
266
+ errorCode,
267
+ };
268
+ }
269
+ save(config, args.configFile);
270
+ const cleanupAttempts = connection.managedCopyCleanup?.cleanupAttempts ?? 0;
271
+ result.cleanupPending.push({ path, cleanupAttempts, errorCode });
272
+ }
273
+ }
274
+ return result;
275
+ }
101
276
  export function removePairing(url, workspaceId, configFile) {
102
277
  const config = loadConfig(configFile);
103
278
  if (!config)
package/dist/git-range.js CHANGED
@@ -28,8 +28,10 @@ export function gitRangeEnvironment(parent = process.env) {
28
28
  delete environment.GIT_CONFIG_NOSYSTEM;
29
29
  return environment;
30
30
  }
31
- function runGit(repoPath, args, outputLimit, input, environment) {
31
+ function runGit(repoPath, args, outputLimit, input, environment, execution) {
32
32
  return new Promise((resolve, reject) => {
33
+ if (execution?.signal.aborted)
34
+ return reject(new GitCommandFailure('resource'));
33
35
  const child = spawn('git', ['--no-replace-objects', ...args], {
34
36
  cwd: repoPath,
35
37
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -41,19 +43,35 @@ function runGit(repoPath, args, outputLimit, input, environment) {
41
43
  let stdoutBytes = 0;
42
44
  let stderrBytes = 0;
43
45
  let settled = false;
46
+ let closeProcess;
47
+ const processClosed = new Promise((done) => { closeProcess = done; });
48
+ const unregister = execution?.registerReader({
49
+ cancel: async () => {
50
+ if (child.exitCode === null && child.signalCode === null)
51
+ child.kill('SIGKILL');
52
+ await processClosed;
53
+ },
54
+ }) ?? (() => undefined);
55
+ const abort = () => {
56
+ if (child.exitCode === null && child.signalCode === null)
57
+ child.kill('SIGKILL');
58
+ };
59
+ execution?.signal.addEventListener('abort', abort, { once: true });
44
60
  const finish = (error, value) => {
45
61
  if (settled)
46
62
  return;
47
63
  settled = true;
48
64
  clearTimeout(timer);
65
+ execution?.signal.removeEventListener('abort', abort);
66
+ unregister();
49
67
  if (error)
50
68
  reject(error);
51
69
  else
52
70
  resolve(value);
53
71
  };
54
72
  const overflow = () => {
55
- child.kill('SIGKILL');
56
- finish(new GitCommandFailure('resource'));
73
+ if (child.exitCode === null && child.signalCode === null)
74
+ child.kill('SIGKILL');
57
75
  };
58
76
  child.stdout?.on('data', (chunk) => {
59
77
  stdoutBytes += chunk.length;
@@ -68,15 +86,19 @@ function runGit(repoPath, args, outputLimit, input, environment) {
68
86
  if (stdoutBytes + stderrBytes > outputLimit)
69
87
  overflow();
70
88
  });
71
- child.on('error', () => finish(new GitCommandFailure('resource')));
89
+ child.on('error', () => {
90
+ closeProcess();
91
+ finish(new GitCommandFailure('resource'));
92
+ });
72
93
  child.on('close', (code, signal) => {
94
+ closeProcess();
73
95
  if (signal || code === null)
74
96
  return finish(new GitCommandFailure('resource'));
75
97
  finish(undefined, { stdout: Buffer.concat(stdout), exitCode: code, stderr: Buffer.concat(stderr).toString('utf8') });
76
98
  });
77
99
  const timer = setTimeout(() => {
78
- child.kill('SIGKILL');
79
- finish(new GitCommandFailure('resource'));
100
+ if (child.exitCode === null && child.signalCode === null)
101
+ child.kill('SIGKILL');
80
102
  }, GIT_RANGE_COMMAND_TIMEOUT_MS);
81
103
  if (input)
82
104
  child.stdin?.end(input);
@@ -166,7 +188,7 @@ export function fitsGitRangeTransport(result) {
166
188
  return Buffer.byteLength(JSON.stringify({ result, filesRead: [] }), 'utf8') <= GIT_RANGE_MAX_HTTP_BYTES;
167
189
  }
168
190
  const failure = (code) => ({ ok: false, code });
169
- export async function runGitRangeTask(task, repoPath, connection) {
191
+ export async function runGitRangeTask(task, repoPath, connection, acquired) {
170
192
  const payload = task.payload;
171
193
  const branch = payload.branch;
172
194
  const base = payload.base;
@@ -178,40 +200,54 @@ export async function runGitRangeTask(task, repoPath, connection) {
178
200
  || (payload.schemaProbe !== undefined && !validSchemaProbe(payload.schemaProbe))) {
179
201
  return { result: failure('history_failed'), filesRead: [] };
180
202
  }
181
- const branchCheck = await runGit(repoPath, ['check-ref-format', '--branch', branch], GIT_RANGE_METADATA_OUTPUT_BYTES);
182
- const remoteList = await runGit(repoPath, ['remote'], GIT_RANGE_METADATA_OUTPUT_BYTES);
203
+ if (acquired && (acquired.base !== base || !SHA.test(acquired.head))) {
204
+ return { result: failure('history_failed'), filesRead: [] };
205
+ }
206
+ const executeGit = (args, outputLimit, input, environment = acquired?.environment) => runGit(repoPath, args, outputLimit, input, environment, acquired);
207
+ const branchCheck = await executeGit(['check-ref-format', '--branch', branch], GIT_RANGE_METADATA_OUTPUT_BYTES);
208
+ const remoteList = acquired ? null : await executeGit(['remote'], GIT_RANGE_METADATA_OUTPUT_BYTES);
183
209
  if (branchCheck.exitCode !== 0)
184
210
  return { result: failure('branch_missing'), filesRead: [] };
185
- const remotes = exactLine(Buffer.concat([remoteList.stdout, remoteList.stdout.length === 0 ? Buffer.from('\n') : Buffer.alloc(0)]));
186
- if (remoteList.exitCode !== 0 || !remotes.split('\n').includes(remote))
187
- return { result: failure('branch_missing'), filesRead: [] };
188
- destination = `refs/product-console/git-range/${task.id}/${randomUUID()}/head`;
189
- const destinationCheck = await runGit(repoPath, ['check-ref-format', destination], GIT_RANGE_METADATA_OUTPUT_BYTES);
190
- const sourceCheck = await runGit(repoPath, ['check-ref-format', `refs/heads/${branch}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
191
- if (destinationCheck.exitCode !== 0 || sourceCheck.exitCode !== 0)
192
- return { result: failure('branch_missing'), filesRead: [] };
193
- const fetchEnvironment = await managedFetchEnvironment(repoPath);
194
- delete fetchEnvironment.GIT_CONFIG_NOSYSTEM;
195
- const fetched = await runGit(repoPath, [
196
- 'fetch', '--no-tags', '--refmap=', '--no-write-fetch-head', '--no-recurse-submodules', '--',
197
- remote, `+refs/heads/${branch}:${destination}`,
198
- ], GIT_RANGE_METADATA_OUTPUT_BYTES, undefined, fetchEnvironment);
199
- reportBackgroundFetch(repoPath, { ok: fetched.exitCode === 0, stderr: fetched.stderr });
200
- if (fetched.exitCode !== 0)
201
- return { result: failure('fetch_failed'), filesRead: [] };
202
- const resolved = await runGit(repoPath, ['rev-parse', '--verify', `${destination}^{commit}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
203
- if (resolved.exitCode !== 0)
204
- return { result: failure('history_failed'), filesRead: [] };
205
- const head = exactLine(resolved.stdout);
206
- if (!SHA.test(head))
207
- return { result: failure('history_failed'), filesRead: [] };
208
- const shallowOutput = await runGit(repoPath, ['rev-parse', '--is-shallow-repository'], GIT_RANGE_METADATA_OUTPUT_BYTES);
209
- if (shallowOutput.exitCode !== 0)
210
- return { result: failure('history_failed'), filesRead: [] };
211
- const shallowText = exactLine(shallowOutput.stdout);
212
- if (shallowText !== 'true' && shallowText !== 'false')
213
- return { result: failure('history_failed'), filesRead: [] };
214
- const historyShallow = shallowText === 'true';
211
+ let head;
212
+ let historyShallow;
213
+ if (acquired) {
214
+ head = acquired.head;
215
+ historyShallow = acquired.historyShallow;
216
+ }
217
+ else {
218
+ const remotes = exactLine(Buffer.concat([remoteList.stdout,
219
+ remoteList.stdout.length === 0 ? Buffer.from('\n') : Buffer.alloc(0)]));
220
+ if (remoteList.exitCode !== 0 || !remotes.split('\n').includes(remote)) {
221
+ return { result: failure('branch_missing'), filesRead: [] };
222
+ }
223
+ destination = `refs/product-console/git-range/${task.id}/${randomUUID()}/head`;
224
+ const destinationCheck = await executeGit(['check-ref-format', destination], GIT_RANGE_METADATA_OUTPUT_BYTES);
225
+ const sourceCheck = await executeGit(['check-ref-format', `refs/heads/${branch}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
226
+ if (destinationCheck.exitCode !== 0 || sourceCheck.exitCode !== 0)
227
+ return { result: failure('branch_missing'), filesRead: [] };
228
+ const fetchEnvironment = await managedFetchEnvironment(repoPath);
229
+ delete fetchEnvironment.GIT_CONFIG_NOSYSTEM;
230
+ const fetched = await executeGit([
231
+ 'fetch', '--no-tags', '--refmap=', '--no-write-fetch-head', '--no-recurse-submodules', '--',
232
+ remote, `+refs/heads/${branch}:${destination}`,
233
+ ], GIT_RANGE_METADATA_OUTPUT_BYTES, undefined, fetchEnvironment);
234
+ reportBackgroundFetch(repoPath, { ok: fetched.exitCode === 0, stderr: fetched.stderr });
235
+ if (fetched.exitCode !== 0)
236
+ return { result: failure('fetch_failed'), filesRead: [] };
237
+ const resolved = await executeGit(['rev-parse', '--verify', `${destination}^{commit}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
238
+ if (resolved.exitCode !== 0)
239
+ return { result: failure('history_failed'), filesRead: [] };
240
+ head = exactLine(resolved.stdout);
241
+ if (!SHA.test(head))
242
+ return { result: failure('history_failed'), filesRead: [] };
243
+ const shallowOutput = await executeGit(['rev-parse', '--is-shallow-repository'], GIT_RANGE_METADATA_OUTPUT_BYTES);
244
+ if (shallowOutput.exitCode !== 0)
245
+ return { result: failure('history_failed'), filesRead: [] };
246
+ const shallowText = exactLine(shallowOutput.stdout);
247
+ if (shallowText !== 'true' && shallowText !== 'false')
248
+ return { result: failure('history_failed'), filesRead: [] };
249
+ historyShallow = shallowText === 'true';
250
+ }
215
251
  if (base === null) {
216
252
  const result = { ok: true, mode: 'head_only', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: false, truncatedFiles: false, historyShallow };
217
253
  return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
@@ -220,7 +256,12 @@ export async function runGitRangeTask(task, repoPath, connection) {
220
256
  const result = { ok: true, mode: 'range', head, base, commits: [], changedPaths: [], totalCommits: 0, baseUnavailable: false, truncatedFiles: false, historyShallow };
221
257
  return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
222
258
  }
223
- const baseCheck = await runGit(repoPath, ['cat-file', '--batch-check'], GIT_RANGE_METADATA_OUTPUT_BYTES, Buffer.from(`${base}\n`));
259
+ if (acquired?.baseUnavailableReason) {
260
+ return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [],
261
+ totalCommits: null, baseUnavailable: true,
262
+ baseUnavailableReason: acquired.baseUnavailableReason, truncatedFiles: false, historyShallow }, filesRead: [] };
263
+ }
264
+ const baseCheck = await executeGit(['cat-file', '--batch-check'], GIT_RANGE_METADATA_OUTPUT_BYTES, Buffer.from(`${base}\n`));
224
265
  if (baseCheck.exitCode !== 0)
225
266
  return { result: failure('history_failed'), filesRead: [] };
226
267
  const baseDescription = exactLine(baseCheck.stdout);
@@ -229,19 +270,19 @@ export async function runGitRangeTask(task, repoPath, connection) {
229
270
  }
230
271
  if (!new RegExp(`^${base} commit [0-9]+$`).test(baseDescription))
231
272
  return { result: failure('history_failed'), filesRead: [] };
232
- if (historyShallow) {
273
+ if (historyShallow && !acquired) {
233
274
  return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'shallow_history', truncatedFiles: false, historyShallow }, filesRead: [] };
234
275
  }
235
- const ancestor = await runGit(repoPath, ['merge-base', '--is-ancestor', base, head], GIT_RANGE_METADATA_OUTPUT_BYTES);
276
+ const ancestor = await executeGit(['merge-base', '--is-ancestor', base, head], GIT_RANGE_METADATA_OUTPUT_BYTES);
236
277
  if (ancestor.exitCode === 1) {
237
278
  return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'not_ancestor', truncatedFiles: false, historyShallow }, filesRead: [] };
238
279
  }
239
280
  if (ancestor.exitCode !== 0)
240
281
  return { result: failure('history_failed'), filesRead: [] };
241
282
  const [enumerated, counted, diff] = await Promise.all([
242
- runGit(repoPath, ['rev-list', '--topo-order', '--reverse', `${base}..${head}`, '--'], GIT_RANGE_ENUMERATION_OUTPUT_BYTES),
243
- runGit(repoPath, ['rev-list', '--count', `${base}..${head}`, '--'], GIT_RANGE_METADATA_OUTPUT_BYTES),
244
- runGit(repoPath, ['diff', '--no-color', '--name-status', '-z', '-M', base, head], GIT_RANGE_PATH_OUTPUT_BYTES),
283
+ executeGit(['rev-list', '--topo-order', '--reverse', `${base}..${head}`, '--'], GIT_RANGE_ENUMERATION_OUTPUT_BYTES),
284
+ executeGit(['rev-list', '--count', `${base}..${head}`, '--'], GIT_RANGE_METADATA_OUTPUT_BYTES),
285
+ executeGit(['diff', '--no-color', '--name-status', '-z', '-M', base, head], GIT_RANGE_PATH_OUTPUT_BYTES),
245
286
  ]);
246
287
  if (enumerated.exitCode !== 0 || counted.exitCode !== 0 || diff.exitCode !== 0)
247
288
  return { result: failure('history_failed'), filesRead: [] };
@@ -259,7 +300,7 @@ export async function runGitRangeTask(task, repoPath, connection) {
259
300
  return { result: failure('history_failed'), filesRead: [] };
260
301
  }
261
302
  const selected = oids.slice(0, GIT_RANGE_MAX_COMMITS);
262
- const objects = await runGit(repoPath, ['cat-file', '--batch'], GIT_RANGE_OBJECT_OUTPUT_BYTES, Buffer.from(`${selected.join('\n')}\n`));
303
+ const objects = await executeGit(['cat-file', '--batch'], GIT_RANGE_OBJECT_OUTPUT_BYTES, Buffer.from(`${selected.join('\n')}\n`));
263
304
  if (objects.exitCode !== 0)
264
305
  return { result: failure('history_failed'), filesRead: [] };
265
306
  const commits = parseCommitObjects(objects.stdout, selected);
@@ -272,6 +313,7 @@ export async function runGitRangeTask(task, repoPath, connection) {
272
313
  head,
273
314
  changedPaths: paths.changedPaths,
274
315
  probe: payload.schemaProbe,
316
+ environment: acquired?.environment,
275
317
  });
276
318
  const result = {
277
319
  ok: true, mode: 'range', head, base, commits, changedPaths: paths.changedPaths,