sfdx-git-delta 7.0.1 → 7.2.0

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 (139) hide show
  1. package/README.md +12 -3
  2. package/lib/adapter/GitAdapter.d.ts +25 -64
  3. package/lib/adapter/GitAdapter.js +352 -483
  4. package/lib/adapter/GitAdapter.js.map +1 -1
  5. package/lib/adapter/gitBlobReader.d.ts +8 -10
  6. package/lib/adapter/gitBlobReader.js.map +1 -1
  7. package/lib/adapter/ioExecutor.d.ts +5 -4
  8. package/lib/adapter/ioExecutor.js +22 -14
  9. package/lib/adapter/ioExecutor.js.map +1 -1
  10. package/lib/adapter/pathMatching.d.ts +9 -0
  11. package/lib/adapter/pathMatching.js +83 -0
  12. package/lib/adapter/pathMatching.js.map +1 -0
  13. package/lib/adapter/tsgitErrorMap.d.ts +7 -0
  14. package/lib/adapter/tsgitErrorMap.js +32 -0
  15. package/lib/adapter/tsgitErrorMap.js.map +1 -0
  16. package/lib/commands/sgd/source/delta.js.map +1 -1
  17. package/lib/constant/gitConstants.d.ts +0 -2
  18. package/lib/constant/gitConstants.js +0 -8
  19. package/lib/constant/gitConstants.js.map +1 -1
  20. package/lib/constant/metadataConstants.d.ts +1 -0
  21. package/lib/constant/metadataConstants.js +1 -0
  22. package/lib/constant/metadataConstants.js.map +1 -1
  23. package/lib/main.d.ts +2 -2
  24. package/lib/main.js +64 -35
  25. package/lib/main.js.map +1 -1
  26. package/lib/post-processor/baseProcessor.d.ts +9 -6
  27. package/lib/post-processor/baseProcessor.js +5 -6
  28. package/lib/post-processor/baseProcessor.js.map +1 -1
  29. package/lib/post-processor/changesManifestProcessor.d.ts +3 -2
  30. package/lib/post-processor/changesManifestProcessor.js +5 -4
  31. package/lib/post-processor/changesManifestProcessor.js.map +1 -1
  32. package/lib/post-processor/flowTranslationProcessor.d.ts +8 -7
  33. package/lib/post-processor/flowTranslationProcessor.js +20 -19
  34. package/lib/post-processor/flowTranslationProcessor.js.map +1 -1
  35. package/lib/post-processor/includeProcessor.d.ts +6 -5
  36. package/lib/post-processor/includeProcessor.js +7 -6
  37. package/lib/post-processor/includeProcessor.js.map +1 -1
  38. package/lib/post-processor/packageGenerator.d.ts +3 -2
  39. package/lib/post-processor/packageGenerator.js +5 -4
  40. package/lib/post-processor/packageGenerator.js.map +1 -1
  41. package/lib/post-processor/postProcessorManager.d.ts +5 -7
  42. package/lib/post-processor/postProcessorManager.js +23 -31
  43. package/lib/post-processor/postProcessorManager.js.map +1 -1
  44. package/lib/service/botHandler.d.ts +1 -2
  45. package/lib/service/botHandler.js +17 -10
  46. package/lib/service/botHandler.js.map +1 -1
  47. package/lib/service/containedDecomposedHandler.d.ts +4 -5
  48. package/lib/service/containedDecomposedHandler.js +13 -12
  49. package/lib/service/containedDecomposedHandler.js.map +1 -1
  50. package/lib/service/customFieldHandler.d.ts +1 -2
  51. package/lib/service/customFieldHandler.js +7 -6
  52. package/lib/service/customFieldHandler.js.map +1 -1
  53. package/lib/service/customLabelHandler.d.ts +1 -2
  54. package/lib/service/customLabelHandler.js +3 -3
  55. package/lib/service/customLabelHandler.js.map +1 -1
  56. package/lib/service/customObjectHandler.d.ts +3 -4
  57. package/lib/service/customObjectHandler.js +10 -9
  58. package/lib/service/customObjectHandler.js.map +1 -1
  59. package/lib/service/decomposedHandler.d.ts +3 -4
  60. package/lib/service/decomposedHandler.js +5 -4
  61. package/lib/service/decomposedHandler.js.map +1 -1
  62. package/lib/service/diffLineInterpreter.d.ts +3 -3
  63. package/lib/service/diffLineInterpreter.js +18 -14
  64. package/lib/service/diffLineInterpreter.js.map +1 -1
  65. package/lib/service/flowHandler.d.ts +1 -2
  66. package/lib/service/flowHandler.js +9 -4
  67. package/lib/service/flowHandler.js.map +1 -1
  68. package/lib/service/inFileHandler.d.ts +8 -9
  69. package/lib/service/inFileHandler.js +28 -27
  70. package/lib/service/inFileHandler.js.map +1 -1
  71. package/lib/service/inFolderHandler.d.ts +4 -5
  72. package/lib/service/inFolderHandler.js +6 -5
  73. package/lib/service/inFolderHandler.js.map +1 -1
  74. package/lib/service/inResourceHandler.d.ts +4 -5
  75. package/lib/service/inResourceHandler.js +8 -7
  76. package/lib/service/inResourceHandler.js.map +1 -1
  77. package/lib/service/objectTranslationHandler.d.ts +1 -2
  78. package/lib/service/objectTranslationHandler.js +6 -5
  79. package/lib/service/objectTranslationHandler.js.map +1 -1
  80. package/lib/service/reportingFolderHandler.d.ts +6 -7
  81. package/lib/service/reportingFolderHandler.js +6 -6
  82. package/lib/service/reportingFolderHandler.js.map +1 -1
  83. package/lib/service/sharedFolderHandler.d.ts +4 -5
  84. package/lib/service/sharedFolderHandler.js +6 -6
  85. package/lib/service/sharedFolderHandler.js.map +1 -1
  86. package/lib/service/standardHandler.d.ts +5 -9
  87. package/lib/service/standardHandler.js +28 -35
  88. package/lib/service/standardHandler.js.map +1 -1
  89. package/lib/service/typeHandlerFactory.d.ts +3 -3
  90. package/lib/service/typeHandlerFactory.js +6 -6
  91. package/lib/service/typeHandlerFactory.js.map +1 -1
  92. package/lib/types/config.d.ts +5 -1
  93. package/lib/types/handlerResult.d.ts +7 -7
  94. package/lib/types/handlerResult.js +10 -1
  95. package/lib/types/handlerResult.js.map +1 -1
  96. package/lib/types/work.d.ts +1 -1
  97. package/lib/utils/arrayUtils.d.ts +1 -1
  98. package/lib/utils/arrayUtils.js.map +1 -1
  99. package/lib/utils/bundleRollup.d.ts +6 -0
  100. package/lib/utils/bundleRollup.js +62 -0
  101. package/lib/utils/bundleRollup.js.map +1 -0
  102. package/lib/utils/changeSet.d.ts +12 -15
  103. package/lib/utils/changeSet.js +23 -120
  104. package/lib/utils/changeSet.js.map +1 -1
  105. package/lib/utils/changesAssembly.d.ts +8 -0
  106. package/lib/utils/changesAssembly.js +21 -0
  107. package/lib/utils/changesAssembly.js.map +1 -0
  108. package/lib/utils/configValidator.d.ts +7 -6
  109. package/lib/utils/configValidator.js +32 -11
  110. package/lib/utils/configValidator.js.map +1 -1
  111. package/lib/utils/fsHelper.d.ts +2 -1
  112. package/lib/utils/fsHelper.js +7 -3
  113. package/lib/utils/fsHelper.js.map +1 -1
  114. package/lib/utils/messageSanitizer.d.ts +1 -0
  115. package/lib/utils/messageSanitizer.js +48 -0
  116. package/lib/utils/messageSanitizer.js.map +1 -0
  117. package/lib/utils/metadataDiff/index.d.ts +0 -2
  118. package/lib/utils/metadataDiff/index.js.map +1 -1
  119. package/lib/utils/metadataDiff/streamingDiff.d.ts +1 -3
  120. package/lib/utils/metadataDiff/streamingDiff.js +16 -54
  121. package/lib/utils/metadataDiff/streamingDiff.js.map +1 -1
  122. package/lib/utils/pathspec.d.ts +15 -0
  123. package/lib/utils/pathspec.js +71 -0
  124. package/lib/utils/pathspec.js.map +1 -0
  125. package/lib/utils/renameResolver.d.ts +6 -6
  126. package/lib/utils/renameResolver.js +10 -9
  127. package/lib/utils/renameResolver.js.map +1 -1
  128. package/lib/utils/repoGitDiff.d.ts +2 -0
  129. package/lib/utils/repoGitDiff.js +14 -1
  130. package/lib/utils/repoGitDiff.js.map +1 -1
  131. package/messages/delta.md +26 -0
  132. package/oclif.manifest.json +2 -2
  133. package/package.json +28 -20
  134. package/lib/adapter/gitBatchCatFile.d.ts +0 -42
  135. package/lib/adapter/gitBatchCatFile.js +0 -203
  136. package/lib/adapter/gitBatchCatFile.js.map +0 -1
  137. package/lib/post-processor/bundleRollupProcessor.d.ts +0 -7
  138. package/lib/post-processor/bundleRollupProcessor.js +0 -81
  139. package/lib/post-processor/bundleRollupProcessor.js.map +0 -1
@@ -4,32 +4,70 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { spawn } from 'node:child_process';
7
+ /*
8
+ * In-process git backend for sfdx-git-delta, backed by `@scolladon/tsgit`
9
+ * (zero-dependency, no `git` binary, no subprocess). Object-store reads,
10
+ * diffs, tree walks and blob content all go through the tsgit facade:
11
+ *
12
+ * git rev-parse --verify <ref> -> repo.revParse(ref)
13
+ * git ls-tree --name-only -r <rev> -> repo.primitives.flattenTree
14
+ * git rev-list --max-parents=0 HEAD -> repo.primitives.walkCommits
15
+ * git cat-file --batch / blob -> repo.primitives.readBlob / streamBlob
16
+ * git diff --name-status -M -w -> repo.diff({ recursive,
17
+ * detectRenames, ignoreWhitespace })
18
+ * git archive --format=tar -> tree walk + streamBlob
19
+ * git grep -l -> tree walk + readBlob + RegExp
20
+ * (grepBlobs, shared by
21
+ * grepUnderPaths and
22
+ * grepMatchingPathspecs)
23
+ * git config core.* -> no-op (nothing to configure)
24
+ *
25
+ * Fidelity note: grepBlobs matches content with JS RegExp semantics, not
26
+ * POSIX basic regex. Callers are expected to pass metacharacter-free
27
+ * literals so the two semantics agree; the known pattern set is pinned by
28
+ * gitGrepPatternInventory.test.ts (new callers must extend that inventory).
29
+ * A second fidelity axis follows from the path-matching split: path
30
+ * matching is literal-prefix on grepUnderPaths' concrete-path surface and
31
+ * wildmatch on grepMatchingPathspecs' pattern surface — only sgd-constructed
32
+ * values, already validated to carry no wildcard metacharacters, reach the
33
+ * latter.
34
+ */
35
+ import { once } from 'node:events';
8
36
  import { createReadStream } from 'node:fs';
9
- import { readFile } from 'node:fs/promises';
37
+ import { readFile, stat } from 'node:fs/promises';
10
38
  import { join } from 'node:path/posix';
11
- import { createInterface } from 'node:readline';
12
- import { PassThrough } from 'node:stream';
13
- import { TAB } from '../constant/cliConstants.js';
39
+ import { PassThrough, Readable } from 'node:stream';
40
+ import { openRepository, } from '@scolladon/tsgit';
14
41
  import { UTF8_ENCODING } from '../constant/fsConstants.js';
15
- import { ADDITION, DELETION, HEAD, IGNORE_WHITESPACE_PARAMS, MODIFICATION, NUM_STAT_CHANGE_INFORMATION, RENAMED, } from '../constant/gitConstants.js';
42
+ import { HEAD } from '../constant/gitConstants.js';
16
43
  import { pushAll } from '../utils/arrayUtils.js';
17
44
  import { getErrorMessage } from '../utils/errorUtils.js';
18
45
  import { treatPathSep } from '../utils/fsUtils.js';
19
46
  import { getLFSObjectContentPath, isLFS } from '../utils/gitLfsHelper.js';
20
47
  import { log } from '../utils/LoggingDecorator.js';
21
48
  import { Logger, lazy } from '../utils/LoggingService.js';
22
- import { GitBatchCatFile } from './gitBatchCatFile.js';
49
+ import { EscalateToStreamingSignal, SIZE_THRESHOLD, } from './gitBlobReader.js';
50
+ import { buildLiteralMatcher, buildPathspecMatcher, hasRootScope, inScope, nonRootScopes, ROOT_PATHS, toDiffLines, } from './pathMatching.js';
23
51
  import { TreeIndex } from './treeIndex.js';
52
+ import { mapTsgitError } from './tsgitErrorMap.js';
53
+ // walkTree yields directories and gitlinks too; only blob-bearing modes
54
+ // belong in the path -> blob id index (ls-tree -r parity).
55
+ const BLOB_MODES = new Set(['100644', '100755', '120000']);
56
+ // Maps sgd's whitespace-ignoring diff request onto tsgit's data-mode
57
+ // whitespace knobs: whitespace-only modifications drop out of the TreeDiff.
58
+ const IGNORE_WHITESPACE_OPTIONS = {
59
+ ignoreWhitespace: 'all',
60
+ ignoreBlankLines: true,
61
+ };
62
+ // Streaming LFS-pointer detection for streamContent: the literal magic
63
+ // prefix released LFS pointer files start with, and a generous upper
64
+ // bound on pointer body size (real pointers are ~130 bytes; this guards
65
+ // against a corrupt/oversized "pointer" masquerading as one).
24
66
  const LFS_MAGIC = Buffer.from('version https://git-lfs.github.com/spec/v1\n');
25
- const EOL = /\r?\n/;
26
- const ROOT_PATHS = new Set(['', '.', './']);
67
+ const LFS_POINTER_CAP = 1024;
27
68
  export default class GitAdapter {
28
69
  config;
29
70
  static instances = new Map();
30
- // Keyed by repo+to so spread copies of the same config (e.g. ioExecutor's
31
- // per-revision {...config, to: rev}) share one adapter instead of spawning
32
- // a fresh git cat-file subprocess per call.
33
71
  static keyFor(config) {
34
72
  return `${config.repo}\0${config.to}`;
35
73
  }
@@ -40,198 +78,98 @@ export default class GitAdapter {
40
78
  }
41
79
  return GitAdapter.instances.get(key);
42
80
  }
81
+ static async closeAll() {
82
+ for (const instance of GitAdapter.instances.values()) {
83
+ await instance.close();
84
+ }
85
+ GitAdapter.instances.clear();
86
+ }
43
87
  treeIndex;
44
- batchCatFile = null;
45
- // Live-only list of streaming subprocesses: children are appended when
46
- // spawned and spliced on `close` so long-running invocations don't
47
- // accumulate dead process references. Iterated at closeAll() teardown and
48
- // kill()ed if still alive.
49
- streamingChildren = [];
50
- // Cap on stderr buffered per-subprocess: long-running git processes that
51
- // emit progress to stderr would otherwise grow this without bound. The
52
- // final error message truncates at this size.
53
- static STDERR_BUFFER_CAP = 8 * 1024;
54
- // Cap on LFS pointer buffering: real pointers are < 200 bytes; a crafted
55
- // blob with the LFS magic prefix followed by gigabytes of content should
56
- // not OOM the process before validation fails.
57
- static LFS_POINTER_CAP = 1024;
58
- spawnFn = spawn;
88
+ // Per revision: repo-relative path -> blob ObjectId. The tsgit counterpart
89
+ // of `git cat-file --batch` oid:path resolution.
90
+ blobIdIndex;
91
+ repoHandle = null;
59
92
  constructor(config) {
60
93
  this.config = config;
61
94
  this.treeIndex = new Map();
95
+ this.blobIdIndex = new Map();
62
96
  }
63
- /**
64
- * Testability seam: lets unit tests swap in a fake spawn for streaming
65
- * subprocesses. Production always uses `child_process.spawn`.
66
- */
67
- setSpawnFn(spawnFn) {
68
- this.spawnFn = spawnFn;
69
- }
70
- getBatchCatFile() {
71
- if (!this.batchCatFile) {
72
- this.batchCatFile = new GitBatchCatFile(this.config.repo, {
73
- spawnFn: this.spawnFn,
74
- });
75
- }
76
- return this.batchCatFile;
77
- }
78
- closeBatchProcess() {
79
- this.batchCatFile?.close();
80
- this.batchCatFile = null;
81
- for (const child of this.streamingChildren) {
82
- if (child.exitCode === null && !child.killed) {
83
- child.kill();
84
- }
97
+ getRepo() {
98
+ if (!this.repoHandle) {
99
+ this.repoHandle = openRepository({ cwd: this.config.repo });
85
100
  }
86
- this.streamingChildren.length = 0;
101
+ return this.repoHandle;
87
102
  }
88
- static closeAll() {
89
- for (const instance of GitAdapter.instances.values()) {
90
- instance.closeBatchProcess();
103
+ async close() {
104
+ if (this.repoHandle) {
105
+ const repo = await this.repoHandle;
106
+ await repo.dispose();
107
+ this.repoHandle = null;
91
108
  }
92
- GitAdapter.instances.clear();
93
109
  }
94
110
  async configureRepository() {
95
- await this._gitBuffered(['config', 'core.longpaths', 'true']);
96
- await this._gitBuffered(['config', 'core.quotepath', 'off']);
111
+ // core.longpaths / core.quotepath exist to fix `git` CLI output and
112
+ // Windows path handling in subprocesses. tsgit reads the object store
113
+ // directly: nothing to configure.
97
114
  }
98
115
  async parseRev(ref) {
99
- return await this._gitBuffered(['rev-parse', '--verify', ref]);
116
+ try {
117
+ const repo = await this.getRepo();
118
+ return await repo.revParse(ref);
119
+ }
120
+ catch (error) {
121
+ throw mapTsgitError(error, ref);
122
+ }
100
123
  }
101
124
  async preBuildTreeIndex(revision, scopePaths) {
102
125
  if (this.treeIndex.has(revision)) {
103
126
  return;
104
127
  }
105
128
  try {
106
- const args = ['ls-tree', '--name-only', '-r', revision];
107
- if (scopePaths.length > 0) {
108
- args.push('--', ...scopePaths);
109
- }
129
+ const blobIds = await this.indexRevision(revision);
110
130
  const index = new TreeIndex();
111
- for await (const line of this._spawnLines(args)) {
112
- if (line)
113
- index.add(treatPathSep(line));
131
+ const scopes = scopePaths.filter(scope => !ROOT_PATHS.has(scope));
132
+ for (const path of blobIds.keys()) {
133
+ if (scopes.length === 0 || inScope(path, scopes)) {
134
+ index.add(path);
135
+ }
114
136
  }
115
137
  this.treeIndex.set(revision, index);
116
138
  }
117
139
  catch (error) {
118
- // Stryker disable next-line BlockStatement -- equivalent: catch body is observability-only; emptying the body skips the lazy log but tests assert that treeIndex is missing the revision (the swallowed throw is the contract)
119
- Logger.debug(
120
- // Stryker disable next-line StringLiteral,ArrowFunction -- equivalent: lazy log content is observability only; tests don't assert on the lazy log line
121
- lazy `preBuildTreeIndex: scoped ls-tree for '${revision}' failed: ${() => getErrorMessage(error)}`);
140
+ Logger.debug(lazy `preBuildTreeIndex: tree walk for '${revision}' failed: ${() => getErrorMessage(error)}`);
122
141
  }
123
142
  }
124
- /**
125
- * Spawns `git <args>`, streams stdout through a readline interface, and
126
- * yields one line at a time. Replaces the old "run command, split on EOL"
127
- * pattern that accumulated a multi-MB string for megarepo diffs / tree
128
- * listings before any downstream consumer ran.
129
- *
130
- * The spawned child is pushed into streamingChildren so closeAll()
131
- * kills it if teardown happens mid-stream. stderr is drained to the
132
- * debug log; a non-zero exit code rejects the iterator on next read.
133
- */
134
- async *_spawnLines(args) {
135
- // Stryker disable ObjectLiteral,ArrayDeclaration,StringLiteral -- equivalent: spawn options are Node.js child_process internals; tests stub spawnFn and assert on yielded lines, not on the option object passed through to the spawn
136
- const child = this.spawnFn('git', args, {
137
- cwd: this.config.repo,
138
- stdio: ['ignore', 'pipe', 'pipe'],
139
- });
140
- // Stryker restore ObjectLiteral,ArrayDeclaration,StringLiteral
141
- this._trackChild(child);
142
- const stderrChunks = [];
143
- let stderrLen = 0;
144
- child.stderr.on('data', (chunk) => {
145
- // Stryker disable next-line ConditionalExpression,EqualityOperator -- equivalent: stderr buffer cap guard; the test surface only checks the truncated error message at exit so >= vs > and the threshold flip are unobservable
146
- if (stderrLen >= GitAdapter.STDERR_BUFFER_CAP)
147
- return;
148
- stderrChunks.push(chunk);
149
- // Stryker disable next-line AssignmentOperator -- equivalent: same rationale — stderr cap accumulator; -= would underflow but no test feeds >cap stderr to observe
150
- stderrLen += chunk.length;
151
- });
152
- const rl = createInterface({
153
- input: child.stdout,
154
- crlfDelay: Number.POSITIVE_INFINITY,
155
- });
156
- const exitPromise = new Promise((resolve, reject) => {
157
- // Stryker disable next-line StringLiteral -- equivalent: 'error' and 'close' are EventEmitter event names; tests stub child.once with a fake EE that observes only the contract via the resolve/reject side-effect, not the literal name
158
- child.once('error', reject);
159
- child.once('close', resolve);
160
- });
161
- try {
162
- for await (const line of rl) {
163
- yield line;
164
- }
165
- const code = await exitPromise;
166
- if (code !== 0 && code !== null) {
167
- throw this._buildExitError(args, code, stderrChunks);
168
- }
143
+ // Flattens the full tree at `revision` once and caches path -> blob oid.
144
+ // Shared by the tree index, blob reads, archive streaming and grep.
145
+ // flattenTree is the bulk traversal path (one call, no per-entry yields);
146
+ // it takes a tree oid, so the commit is peeled first.
147
+ async indexRevision(revision) {
148
+ const cached = this.blobIdIndex.get(revision);
149
+ if (cached) {
150
+ return cached;
169
151
  }
170
- finally {
171
- rl.close();
172
- // Stryker disable next-line ConditionalExpression,LogicalOperator,EqualityOperator -- equivalent: this is a defensive cleanup gate; in unit tests the spawned child mock has already exited cleanly by the time the finally runs, so the guard's truth value is irrelevant — child.kill() on an already-exited child is a no-op
173
- if (!child.killed && child.exitCode === null)
174
- child.kill();
152
+ const repo = await this.getRepo();
153
+ const revisionId = await repo.revParse(revision);
154
+ // revParse returns the tag OBJECT oid for annotated tags (no auto-peel),
155
+ // so follow the tag chain down to the tagged commit before reading its
156
+ // tree — matching `git ls-tree -r <tag>` semantics.
157
+ let target = await repo.primitives.readObject(revisionId);
158
+ while (target.type === 'tag') {
159
+ target = await repo.primitives.readObject(target.data.object);
175
160
  }
176
- }
177
- /**
178
- * One-shot buffered git invocation. Spawns `git <args>`, drains stdout
179
- * fully, and returns the trimmed UTF-8 string. Used by callers that
180
- * need the complete output as a single string (revparse, raw-style
181
- * commands, config writes) rather than a line-by-line stream.
182
- *
183
- * Mirrors _spawnLines' exit-code handling: non-zero exits throw with
184
- * the stderr tail (capped at STDERR_BUFFER_CAP) in the message. Output
185
- * is trimmed so callers receive the same shape they used to get from
186
- * the legacy `simpleGit({ trimmed: true })` configuration.
187
- */
188
- async _gitBuffered(args) {
189
- const child = this.spawnFn('git', args, {
190
- cwd: this.config.repo,
191
- stdio: ['ignore', 'pipe', 'pipe'],
192
- });
193
- this._trackChild(child);
194
- const stdoutChunks = [];
195
- const stderrChunks = [];
196
- let stderrLen = 0;
197
- child.stdout.on('data', (chunk) => {
198
- stdoutChunks.push(chunk);
199
- });
200
- child.stderr.on('data', (chunk) => {
201
- if (stderrLen >= GitAdapter.STDERR_BUFFER_CAP)
202
- return;
203
- stderrChunks.push(chunk);
204
- stderrLen += chunk.length;
205
- });
206
- const code = await new Promise((resolve, reject) => {
207
- child.once('error', reject);
208
- child.once('close', resolve);
209
- });
210
- if (code !== 0 && code !== null) {
211
- throw this._buildExitError(args, code, stderrChunks);
161
+ if (target.type !== 'commit') {
162
+ throw new Error(`'${revision}' does not resolve to a commit`);
212
163
  }
213
- return Buffer.concat(stdoutChunks).toString('utf8').trim();
214
- }
215
- // Shared by _spawnLines and _gitBuffered: build the rejection thrown when
216
- // git exits with a non-zero, non-null code. Captures the stderr tail
217
- // (capped at STDERR_BUFFER_CAP) into the message for diagnostics.
218
- _buildExitError(args, code, stderrChunks) {
219
- // Stryker disable next-line MethodExpression -- equivalent: trim() strips trailing newlines from git stderr; tests synthesise stderr without trailing whitespace
220
- const stderr = Buffer.concat(stderrChunks)
221
- .subarray(0, GitAdapter.STDERR_BUFFER_CAP)
222
- .toString('utf8')
223
- .trim();
224
- return new Error(`git ${args[0]} exited ${code}${stderr ? `: ${stderr}` : ''}`);
225
- }
226
- _trackChild(child) {
227
- this.streamingChildren.push(child);
228
- child.once('close', () => {
229
- const idx = this.streamingChildren.indexOf(child);
230
- // Stryker disable next-line ConditionalExpression,UnaryOperator -- equivalent: see v8 ignore — idx is always !== -1 because the just-pushed child is still in the array; the guard is a defensive safety net
231
- /* v8 ignore next -- defensive: the close listener is once-only and bound to the tracked child; idx is always >= 0 here */
232
- if (idx !== -1)
233
- this.streamingChildren.splice(idx, 1);
234
- });
164
+ const { entries } = await repo.primitives.flattenTree(target.data.tree);
165
+ const blobIds = new Map();
166
+ for (const [path, entry] of entries) {
167
+ if (BLOB_MODES.has(entry.mode)) {
168
+ blobIds.set(treatPathSep(path), entry.id);
169
+ }
170
+ }
171
+ this.blobIdIndex.set(revision, blobIds);
172
+ return blobIds;
235
173
  }
236
174
  pathExistsImpl(path, revision) {
237
175
  const index = this.treeIndex.get(revision);
@@ -245,197 +183,129 @@ export default class GitAdapter {
245
183
  return this.pathExistsImpl(path, revision);
246
184
  }
247
185
  async getFirstCommitRef() {
248
- return await this._gitBuffered(['rev-list', '--max-parents=0', HEAD]);
249
- }
250
- async getBufferContent(forRef) {
251
- let content = await this.getBatchCatFile().getContent(forRef.oid, forRef.path);
252
- if (isLFS(content)) {
253
- const lfsPath = getLFSObjectContentPath(content);
254
- content = await readFile(join(this.config.repo, lfsPath));
186
+ try {
187
+ const repo = await this.getRepo();
188
+ const head = await repo.revParse(HEAD);
189
+ let firstCommit = head;
190
+ for await (const commit of repo.primitives.walkCommits({
191
+ from: [head],
192
+ })) {
193
+ if (commit.data.parents.length === 0) {
194
+ firstCommit = commit.id;
195
+ break;
196
+ }
197
+ }
198
+ return firstCommit;
255
199
  }
256
- return content;
257
- }
258
- async getBufferContentOrEscalate(forRef) {
259
- let content = await this.getBatchCatFile().getContentOrEscalate(forRef.oid, forRef.path);
260
- if (isLFS(content)) {
261
- const lfsPath = getLFSObjectContentPath(content);
262
- content = await readFile(join(this.config.repo, lfsPath));
200
+ catch (error) {
201
+ throw mapTsgitError(error, HEAD);
263
202
  }
264
- return content;
265
203
  }
266
- /**
267
- * Streams a directory from `revision` as `git archive --format=tar`
268
- * entries. Yields `{ path, stream }` per file entry (directories are
269
- * skipped). The tar subprocess is registered in streamingChildren so
270
- * closeAll kills it on teardown.
271
- *
272
- * Callers MUST consume each entry's stream — even when skipping the
273
- * entry — otherwise tar-stream back-pressures and halts parsing. For
274
- * skip cases use `stream.resume()` to drain-and-discard.
275
- */
276
- async *streamArchive(path, revision) {
277
- if (path.startsWith('-') || revision.startsWith('-')) {
278
- throw new Error(`Refusing to spawn git archive for ${path}`);
204
+ async resolveObjectId(forRef) {
205
+ const blobIds = await this.indexRevision(forRef.oid);
206
+ const blobId = blobIds.get(treatPathSep(forRef.path));
207
+ if (!blobId) {
208
+ throw new Error(`Path '${forRef.path}' not found at '${forRef.oid}'`);
279
209
  }
280
- const { extract } = await import('tar-stream');
281
- const extractor = extract();
282
- const child = this.spawnFn('git', ['archive', '--format=tar', revision, '--', path], { cwd: this.config.repo, stdio: ['ignore', 'pipe', 'pipe'] });
283
- this._trackChild(child);
284
- // Stryker disable next-line ArrowFunction -- equivalent: the arrow forwards the spawn error onto the tar-stream extractor; the unit tests assert the extractor's downstream destroy effect, not that the arrow is the literal forwarder
285
- child.on('error', err => extractor.destroy(err));
286
- // Stryker disable BlockStatement,StringLiteral,ArrowFunction -- equivalent: stderr listener is observability-only; tests assert the iterator yields/terminates, not on the lazy log line
287
- child.stderr.on('data', (chunk) => {
288
- Logger.debug(lazy `streamArchive stderr for ${path}@${revision}: ${() => chunk.toString()}`);
289
- });
290
- // Stryker restore BlockStatement,StringLiteral,ArrowFunction
291
- child.stdout.pipe(extractor);
210
+ return blobId;
211
+ }
212
+ async readBlobBuffer(forRef) {
213
+ const repo = await this.getRepo();
214
+ const blobId = await this.resolveObjectId(forRef);
215
+ const blob = await repo.primitives.readBlob(blobId);
216
+ return Buffer.from(blob.content);
217
+ }
218
+ async getBufferContent(forRef) {
292
219
  try {
293
- for await (const entry of extractor) {
294
- if (entry.header.type !== 'file') {
295
- entry.resume();
296
- continue;
297
- }
298
- yield { path: entry.header.name, stream: entry };
220
+ let content = await this.readBlobBuffer(forRef);
221
+ if (isLFS(content)) {
222
+ const lfsPath = getLFSObjectContentPath(content);
223
+ content = await readFile(join(this.config.repo, lfsPath));
224
+ }
225
+ return content;
226
+ }
227
+ catch (error) {
228
+ throw mapTsgitError(error, forRef.oid);
229
+ }
230
+ }
231
+ // tsgit's Blob/BlobStream carry no size field, so the only way to know a
232
+ // blob is oversized is to accumulate streamBlob chunks and watch the
233
+ // running total: crossing SIZE_THRESHOLD escalates the caller onto the
234
+ // dedicated streamContent path instead of materializing further.
235
+ async getBufferContentOrEscalate(forRef) {
236
+ const repo = await this.getRepo();
237
+ const blobId = await this.resolveObjectId(forRef);
238
+ const blobStream = await repo.primitives.streamBlob(blobId);
239
+ const parts = [];
240
+ let length = 0;
241
+ for await (const chunk of blobStream) {
242
+ parts.push(chunk);
243
+ length += chunk.length;
244
+ if (length > SIZE_THRESHOLD) {
245
+ throw new EscalateToStreamingSignal(length, forRef);
299
246
  }
300
247
  }
301
- finally {
302
- // Stryker disable next-line BlockStatement -- equivalent: finally body is reached only when iteration completes/throws; the test surface drains the iterator cleanly so the conditional inside is a no-op (child already exited), and emptying the body skips the no-op kill
303
- // Stryker disable next-line ConditionalExpression -- equivalent: defensive cleanup guard; tests stub child to exit cleanly before reaching this gate, so the kill is a no-op either way
304
- if (!child.killed && child.exitCode === null)
305
- child.kill();
248
+ let content = Buffer.concat(parts, length);
249
+ if (isLFS(content)) {
250
+ // The pointer itself is tiny, so the accumulated-length guard above
251
+ // never fires for LFS-backed files — size the resolved object instead
252
+ // and escalate oversized ones onto the streaming path.
253
+ const lfsFile = join(this.config.repo, getLFSObjectContentPath(content));
254
+ const { size } = await stat(lfsFile);
255
+ if (size > SIZE_THRESHOLD) {
256
+ throw new EscalateToStreamingSignal(size, forRef);
257
+ }
258
+ content = await readFile(lfsFile);
306
259
  }
260
+ return content;
307
261
  }
308
- /**
309
- * Spawns a dedicated `git cat-file blob <oid>` subprocess and returns a
310
- * Readable that peeks the first chunks for LFS pointer magic. On match,
311
- * the spawned subprocess is killed and the Readable is fed from the
312
- * resolved LFS object file. Otherwise bytes are forwarded as-is.
313
- */
262
+ // Peeks the first LFS_MAGIC.length bytes off the pull-based streamBlob
263
+ // iterator: a match hands off to the resolved LFS object file, otherwise
264
+ // the peeked head and every remaining chunk are forwarded as-is. Backed
265
+ // by the async iterator's own pull semantics, so no pause/resume is
266
+ // needed to respect backpressure only out.write()'s own signal.
314
267
  streamContent(forRef) {
315
268
  const out = new PassThrough();
316
- // Defense in depth: `git cat-file blob <ref>` treats a ref starting
317
- // with `-` as an option. Refs come from git diff output so this
318
- // shouldn't happen in normal operation, but a malicious diff or a
319
- // path with a leading dash would give git an option it respects.
320
- // Fail fast with a clear error rather than trust the subprocess.
321
- if (forRef.path.startsWith('-') || forRef.oid.startsWith('-')) {
322
- process.nextTick(() => out.destroy(new Error(`Refusing to spawn git cat-file for ${forRef.path}`)));
323
- return out;
324
- }
325
- const child = this.spawnFn('git', ['cat-file', 'blob', `${forRef.oid}:${forRef.path}`], { cwd: this.config.repo, stdio: ['ignore', 'pipe', 'pipe'] });
326
- this._trackChild(child);
327
- this._wireStreamContent(child, out, forRef);
269
+ this.pipeBlobContent(forRef, out).catch((error) => {
270
+ out.destroy(error instanceof Error ? error : new Error(getErrorMessage(error)));
271
+ });
328
272
  return out;
329
273
  }
330
- _wireStreamContent(child, out, forRef) {
331
- let peeked = [];
332
- let peekedLen = 0;
333
- let decided = false;
334
- const forwardPeeked = () => {
335
- const head = Buffer.concat(peeked, peekedLen);
336
- // Stryker disable next-line ArrayDeclaration -- equivalent: the assignment resets the peek buffer; an injected initial element is overwritten on the next push and the head buffer is already concat'd above
337
- peeked = [];
338
- peekedLen = 0;
339
- return head;
340
- };
341
- const onChunk = (chunk) => {
342
- if (decided) {
343
- // Stryker disable next-line BooleanLiteral -- equivalent: see v8 ignore — backpressure pause is rarely triggered in unit tests, and the drain listener resumes either way
344
- /* v8 ignore next -- defensive: PassThrough rarely returns false here; backpressure handled at write time, drain listener resumes */
345
- if (!out.write(chunk))
346
- child.stdout.pause();
347
- return;
348
- }
349
- peeked.push(chunk);
350
- peekedLen += chunk.length;
351
- // Stryker disable next-line ConditionalExpression -- equivalent: the LFS_MAGIC.length threshold gates the peek-vs-decide flow; the test surface verifies LFS handoff via a chunk >= LFS_MAGIC bytes, so the < flip path is exercised but the false-branch only changes whether the next chunk runs through here vs adds another peek; downstream contract is unchanged
352
- if (peekedLen < LFS_MAGIC.length)
353
- return;
354
- // Stryker disable next-line BooleanLiteral -- equivalent: `decided` is local state; the next event loop tick reads it as true regardless of the literal we set, because the function returns immediately after the magic check
355
- decided = true;
356
- const head = forwardPeeked();
357
- if (head.subarray(0, LFS_MAGIC.length).equals(LFS_MAGIC)) {
358
- this._handoffToLfs(child, out, head);
359
- return;
360
- }
361
- // Stryker disable next-line BooleanLiteral -- equivalent: see v8 ignore — pause is rarely triggered in tests
362
- /* v8 ignore next -- defensive: PassThrough rarely returns false here; backpressure handled at write time, drain listener resumes */
363
- if (!out.write(head))
364
- child.stdout.pause();
365
- };
366
- child.stdout.on('data', onChunk);
367
- // Stryker disable next-line StringLiteral,ArrowFunction -- equivalent: 'drain' event name and the resume-on-drain callback are EventEmitter wiring; the test contract verifies that forwarded bytes land on `out`, not the literal event name
368
- out.on('drain', () => child.stdout.resume());
369
- child.stdout.on('end', () => {
370
- // Stryker disable next-line LogicalOperator,ConditionalExpression,EqualityOperator -- equivalent: this is the flush gate for peeked bytes that never reached LFS_MAGIC.length; tests exercise the gate by feeding a sub-magic stream, so the && arm is covered, but the OR-mutated short-circuit produces the same downstream out.write() call
371
- if (!decided && peekedLen > 0) {
372
- out.write(forwardPeeked());
373
- }
374
- // Stryker disable next-line ConditionalExpression -- equivalent: see v8 ignore — second arm always true after flush
375
- /* v8 ignore next -- defensive: forwardPeeked above zeroes peekedLen, so the second arm always evaluates true after a flush */
376
- if (decided || peekedLen === 0)
377
- out.end();
378
- });
379
- // Stryker disable BlockStatement,StringLiteral,ArrowFunction -- equivalent: stderr listener is observability-only; tests assert that the consumer sees the streamed content, not on the lazy log line
380
- child.stderr.on('data', (chunk) => {
381
- Logger.debug(lazy `streamContent stderr for ${forRef.path}: ${() => chunk.toString()}`);
382
- });
383
- // Stryker restore BlockStatement,StringLiteral,ArrowFunction
384
- child.on('error', err => out.destroy(err));
385
- child.on('close', code => {
386
- // Intentional kills during LFS handoff close with a null/non-zero
387
- // code; destroying `out` here would truncate the piped LFS stream
388
- // the handoff just started.
389
- // Stryker disable next-line ConditionalExpression -- equivalent: this 4-arm guard rejects only the (non-zero, non-null, non-killed, non-destroyed) corner; unit tests stub close with code=0 so the guard short-circuits regardless of mutation, leaving out destinations unaffected
390
- if (code !== 0 && code !== null && !child.killed && !out.destroyed) {
391
- out.destroy(new Error(`git cat-file blob exited ${code}`));
392
- }
393
- });
274
+ async pipeBlobContent(forRef, out) {
275
+ const repo = await this.getRepo();
276
+ const blobId = await this.resolveObjectId(forRef);
277
+ const blobStream = await repo.primitives.streamBlob(blobId);
278
+ const chunks = normalizeChunks(blobStream);
279
+ const { head, exhausted } = await peekHead(chunks);
280
+ if (isLfsPointer(head)) {
281
+ await this.pipeLfsObject(chunks, head, out);
282
+ return;
283
+ }
284
+ await forwardChunks(chunks, out, head, exhausted);
394
285
  }
395
- _handoffToLfs(child, out, head) {
396
- const pointerParts = [head];
397
- let pointerLen = head.length;
398
- let aborted = false;
399
- // Replace both data and end listeners from _wireStreamContent: the
400
- // original end listener calls out.end() when decided===true, which would
401
- // close `out` before the LFS file stream begins piping into it and the
402
- // consumer would receive an empty/truncated payload.
403
- child.stdout.removeAllListeners('data');
404
- child.stdout.removeAllListeners('end');
405
- child.stdout.on('data', (c) => {
406
- // Stryker disable next-line ConditionalExpression -- equivalent: re-entry guard for chunks arriving after abort triggered out.destroy(); tests don't fire a multi-chunk abort sequence so the false-flip is unobservable
407
- if (aborted)
408
- return;
409
- pointerParts.push(c);
410
- pointerLen += c.length;
411
- // Stryker disable next-line ConditionalExpression -- equivalent: LFS pointer cap guard; unit tests fixture LFS pointers under the cap (real pointers are <200 bytes, cap is 1024), so the > flip path is unobservable
412
- if (pointerLen > GitAdapter.LFS_POINTER_CAP) {
413
- aborted = true;
414
- out.destroy(new Error('LFS pointer exceeds expected size'));
415
- }
416
- });
417
- child.stdout.on('end', () => {
418
- if (aborted)
419
- return;
420
- try {
421
- const pointer = Buffer.concat(pointerParts, pointerLen);
422
- const lfsPath = getLFSObjectContentPath(pointer);
423
- // Stryker disable next-line ConditionalExpression -- equivalent: this is a Readable.on('error') wiring that propagates ENOENT/permission errors during the LFS file read; unit tests fixture an existing LFS file so the error listener never fires
424
- createReadStream(join(this.config.repo, lfsPath))
425
- .on('error', err => out.destroy(err))
426
- .pipe(out);
427
- }
428
- catch (err) {
429
- /* v8 ignore next -- defensive: getLFSObjectContentPath / createReadStream throw Error instances in practice; the String() fallback exists for non-Error throws */
430
- out.destroy(err instanceof Error ? err : new Error(String(err)));
431
- }
432
- });
433
- // Stryker disable next-line ConditionalExpression -- equivalent: see v8 ignore — child is always alive here
434
- /* v8 ignore next -- defensive: _handoffToLfs is reached after the first peek; the child is alive at this point */
435
- if (!child.killed)
436
- child.kill();
286
+ async pipeLfsObject(chunks, head, out) {
287
+ const pointer = await accumulatePointer(chunks, head);
288
+ const lfsPath = getLFSObjectContentPath(pointer);
289
+ createReadStream(join(this.config.repo, lfsPath))
290
+ .on('error', (error) => out.destroy(error))
291
+ .pipe(out);
292
+ }
293
+ async *streamArchive(path, revision) {
294
+ const repo = await this.getRepo();
295
+ const blobIds = await this.indexRevision(revision);
296
+ for (const [filePath, blobId] of blobIds) {
297
+ if (!inScope(filePath, [path]))
298
+ continue;
299
+ const blob = await repo.primitives.streamBlob(blobId);
300
+ yield {
301
+ path: filePath,
302
+ stream: Readable.from(blob, { objectMode: false }),
303
+ };
304
+ }
437
305
  }
438
306
  async getStringContent(forRef) {
307
+ // getBufferContent already maps raw tsgit errors — mapping exactly once
308
+ // keeps the released error shapes intact (no double-wrapped messages).
439
309
  const content = await this.getBufferContent(forRef);
440
310
  return content.toString(UTF8_ENCODING);
441
311
  }
@@ -445,7 +315,6 @@ export default class GitAdapter {
445
315
  return [];
446
316
  if (ROOT_PATHS.has(path))
447
317
  return index.allPaths();
448
- // Stryker disable next-line ConditionalExpression -- equivalent: file-vs-dir fast path; flipping to false routes file paths through getFilesUnder which returns the same single-element array because the trie navigation lands on the file node
449
318
  if (index.has(path))
450
319
  return [path];
451
320
  return index.getFilesUnder(path);
@@ -466,149 +335,92 @@ export default class GitAdapter {
466
335
  return [];
467
336
  return index.listChildren(dir);
468
337
  }
469
- async gitGrep(pattern, path, revision = this.config.to) {
338
+ // Concrete-path surface: `path` comes straight off the repository (a git
339
+ // diff path run through treatPathSep, or MetadataElement.basePath) and is
340
+ // matched by literal directory prefix — never as a wildmatch pathspec, so
341
+ // a metacharacter incidentally present in a real path (e.g. an object
342
+ // folder named `Custom[1]__c`) can never be misread as a glob.
343
+ async grepUnderPaths(pattern, path, revision = this.config.to) {
344
+ return this.grepBlobs(pattern, path, revision, buildLiteralMatcher);
345
+ }
346
+ // Pattern surface: `path` is an sgd-constructed pathspec (e.g.
347
+ // `<source>/*.translation-meta.xml`) and is matched with git pathspec
348
+ // wildmatch semantics (literal + glob).
349
+ async grepMatchingPathspecs(pattern, path, revision = this.config.to) {
350
+ return this.grepBlobs(pattern, path, revision, buildPathspecMatcher);
351
+ }
352
+ async grepBlobs(pattern, path, revision, buildMatcher) {
470
353
  try {
354
+ const repo = await this.getRepo();
471
355
  const paths = Array.isArray(path) ? path : [path];
472
- const result = await this._gitBuffered([
473
- 'grep',
474
- '-l',
475
- pattern,
476
- revision,
477
- '--',
478
- ...paths,
479
- ]);
480
- return result
481
- .split(EOL)
482
- .filter(line => line)
483
- .map(line => treatPathSep(line.slice(line.indexOf(':') + 1)));
356
+ const blobIds = await this.indexRevision(revision);
357
+ const matcher = new RegExp(pattern);
358
+ const matchesPath = buildMatcher(paths);
359
+ const matches = [];
360
+ for (const [filePath, blobId] of blobIds) {
361
+ if (!matchesPath(filePath))
362
+ continue;
363
+ const blob = await repo.primitives.readBlob(blobId);
364
+ if (matcher.test(Buffer.from(blob.content).toString(UTF8_ENCODING))) {
365
+ matches.push(filePath);
366
+ }
367
+ }
368
+ return matches;
484
369
  }
485
370
  catch (error) {
486
- // Stryker disable next-line StringLiteral,ArrowFunction -- equivalent: catch is observability-only; tests assert on the empty-array return, not the lazy log line
487
- Logger.debug(
488
- // Stryker disable next-line StringLiteral,ArrowFunction -- equivalent: lazy log content is observability only
489
- lazy `gitGrep: grep for '${pattern}' in '${path}' at '${revision}' failed: ${() => getErrorMessage(error)}`);
371
+ Logger.debug(lazy `grepBlobs: grep for '${pattern}' in '${path}' at '${revision}' failed: ${() => getErrorMessage(error)}`);
490
372
  return [];
491
373
  }
492
374
  }
493
- // Fast path (no whitespace ignore): one `git diff --name-status` call.
494
- // Rename detection (`-M` + `R` filter) is gated behind
495
- // `config.changesManifest` so the default sgd pipeline emits the same
496
- // A/M/D line shape as before this feature. When enabled, renames surface
497
- // as `R<score>\tfrom\tto` lines that RepoGitDiff splits into synthetic
498
- // A/D while recording the rename pair for ChangeSet.
499
- //
500
- // Whitespace path: three (or four, when rename detection is on) parallel
501
- // `git diff --numstat` calls, one per --diff-filter. `--name-status` does
502
- // NOT honor `--ignore-all-space` (git decides A/M/D from blob SHAs for
503
- // that mode, so a whitespace-only change still appears as `M`). Only
504
- // `--numstat` computes a real content diff under the whitespace flags,
505
- // so files with 0/0 line changes drop out naturally. When rename
506
- // detection is enabled the R call uses `-z` so it can sidestep numstat's
507
- // brace/arrow rename-path encoding.
508
- //
509
- // The Promise.all fans out to 4 items with rename detection on. That's a
510
- // localised exception to the CLAUDE.local.md ≤3 bounded-Promise.all
511
- // guideline: the four `--diff-filter`s are the canonical per-kind split,
512
- // so hiding R as an out-of-band sequential await just obscures the real
513
- // symmetric fan-out without changing the resource footprint.
514
- async *streamDiffLines() {
515
- const detectRenames = Boolean(this.config.changesManifest);
516
- if (!this.config.ignoreWhitespace) {
517
- const args = [
518
- 'diff',
519
- '--name-status',
520
- ...(detectRenames ? ['-M'] : ['--no-renames']),
521
- `--diff-filter=${detectRenames ? 'AMDR' : 'AMD'}`,
522
- this.config.from,
523
- this.config.to,
524
- '--',
525
- ...this.config.source,
526
- ];
527
- for await (const line of this._spawnLines(args)) {
528
- if (line)
529
- yield treatPathSep(line);
530
- }
531
- return;
532
- }
533
- // When rename detection is on, the A/M/D filters also run with -M so
534
- // renamed files drop out (reclassified to R by git), and the dedicated
535
- // R call is the single source of rename lines — no dedup needed.
536
- // Without rename detection, A/M/D keep their pre-feature line shape via
537
- // --no-renames.
538
- const filters = detectRenames
539
- ? [ADDITION, MODIFICATION, DELETION, RENAMED]
540
- : [ADDITION, MODIFICATION, DELETION];
541
- // Numstat path runs one git invocation per change-type; we still emit
542
- // each batch as it lands so downstream filters can begin work before
543
- // every filter has finished.
544
- for (const changeType of filters) {
545
- for (const line of await this._getNumstatLines(changeType, detectRenames)) {
375
+ // One facade diff call carries the whole subprocess contract: `recursive`
376
+ // is `git diff -r`, `detectRenames` is `-M` (similarity-based, gated
377
+ // behind `config.changesManifest` like the subprocess `-M`), and the
378
+ // whitespace options drop whitespace-only modifications the way the
379
+ // subprocess numstat path does.
380
+ async *streamDiffLines(verdict, scopes) {
381
+ const { changes } = await this.requestDiff();
382
+ // git unions pathspecs (`-- . src` matches everything), so a root scope
383
+ // must not be filtered out here even when non-root scopes are also
384
+ // configured the full, unfiltered source list is what `inScope`
385
+ // (via toDiffLines) expects to reproduce that union.
386
+ for (const change of changes) {
387
+ verdict.changesSeen++;
388
+ for (const line of toDiffLines(change, scopes)) {
389
+ verdict.linesYielded++;
546
390
  yield line;
547
391
  }
548
392
  }
549
393
  }
550
- // Per-filter numstat call. The R branch uses `-z` because numstat
551
- // otherwise encodes rename paths in three format variants within the
552
- // path column (`{a => b}`, `a/{b => c}/d`, or bare `old => new`). `-z`
553
- // emits `N<TAB>M<TAB>\0<src>\0<dst>\0` for each rename, so we stride-3
554
- // over the NUL-split tokens and synthesise `R<TAB><src><TAB><dst>`
555
- // lines that RepoGitDiff._expandRenames already understands.
556
- //
557
- // A/M/D: streamed via _spawnLines + readline, per-line transform strips
558
- // the leading `N\tM\t` counts and rewrites them to the status prefix.
559
- // R (NUL-delimited): buffered via _gitBuffered because readline is
560
- // newline-oriented and the whole-string split remains cheap for the
561
- // rare rename set.
562
- async _getNumstatLines(changeType, detectRenames) {
563
- if (changeType === RENAMED) {
564
- return this._getRenameLines();
565
- }
566
- const args = [
567
- 'diff',
568
- '--numstat',
569
- ...(detectRenames ? ['-M'] : ['--no-renames']),
570
- ...IGNORE_WHITESPACE_PARAMS,
571
- `--diff-filter=${changeType}`,
572
- this.config.from,
573
- this.config.to,
574
- // Stryker disable next-line StringLiteral -- equivalent: '--' is the git path separator marker; tests stub _spawnLines so the args array is opaque past the call, and the contract enforced is that {from, to, ...source} land in the right slots
575
- '--',
576
- ...this.config.source,
577
- ];
578
- const lines = [];
579
- for await (const line of this._spawnLines(args)) {
580
- // Stryker disable next-line ConditionalExpression -- equivalent: see v8 ignore — _spawnLines is the line splitter; empty lines are unreachable in practice
581
- /* v8 ignore next -- defensive: _spawnLines splits on EOL; trailing/empty lines from git numstat are filtered here */
582
- if (!line)
583
- continue;
584
- lines.push(treatPathSep(line.replace(NUM_STAT_CHANGE_INFORMATION, `${changeType}\t`)));
394
+ // Every non-root scope matched nothing in the drained diff. A path can
395
+ // match several scopes, so this is all-or-nothing rather than per-scope
396
+ // per-scope attribution needs bookkeeping that buys nothing. A root
397
+ // scope alongside non-root ones still means "everything is in scope"
398
+ // (union), so naming the non-root scopes as unmatched would be
399
+ // misleading suppress the verdict entirely when one is present.
400
+ getUnmatchedSourceScopes(verdict, scopes) {
401
+ if (hasRootScope(scopes))
402
+ return [];
403
+ const unmatchable = nonRootScopes(scopes);
404
+ return unmatchable.length > 0 &&
405
+ verdict.changesSeen > 0 &&
406
+ verdict.linesYielded === 0
407
+ ? unmatchable
408
+ : [];
409
+ }
410
+ async requestDiff() {
411
+ try {
412
+ const repo = await this.getRepo();
413
+ return await repo.diff({
414
+ from: this.config.from,
415
+ to: this.config.to,
416
+ recursive: true,
417
+ detectRenames: Boolean(this.config.changesManifest),
418
+ ...(this.config.ignoreWhitespace ? IGNORE_WHITESPACE_OPTIONS : {}),
419
+ });
585
420
  }
586
- return lines;
587
- }
588
- async _getRenameLines() {
589
- const output = await this._gitBuffered([
590
- 'diff',
591
- '--numstat',
592
- '-M',
593
- '-z',
594
- ...IGNORE_WHITESPACE_PARAMS,
595
- `--diff-filter=${RENAMED}`,
596
- this.config.from,
597
- this.config.to,
598
- '--',
599
- ...this.config.source,
600
- ]);
601
- const tokens = output.split('\0');
602
- const lines = [];
603
- // Stryker disable next-line EqualityOperator,ArithmeticOperator,AssignmentOperator -- equivalent: this is the stride-3 loop bound; <= vs < and i-2 vs i+2 only widen/narrow the iteration count by one trailing position whose tokens are undefined and rejected by the `!src || !dst` guard below; i-=3 produces an unbounded negative-index walk that reads tokens[-N] (undefined) and is also rejected by the same guard
604
- for (let i = 0; i + 2 < tokens.length; i += 3) {
605
- const src = tokens[i + 1];
606
- const dst = tokens[i + 2];
607
- if (!src || !dst)
608
- continue;
609
- lines.push(treatPathSep(`${RENAMED}${TAB}${src}${TAB}${dst}`));
421
+ catch (error) {
422
+ throw mapTsgitError(error, `${this.config.from}..${this.config.to}`);
610
423
  }
611
- return lines;
612
424
  }
613
425
  }
614
426
  __decorate([
@@ -637,8 +449,65 @@ __decorate([
637
449
  ], GitAdapter.prototype, "listDirAtRevision", null);
638
450
  __decorate([
639
451
  log
640
- ], GitAdapter.prototype, "gitGrep", null);
452
+ ], GitAdapter.prototype, "grepUnderPaths", null);
453
+ __decorate([
454
+ log
455
+ ], GitAdapter.prototype, "grepMatchingPathspecs", null);
641
456
  __decorate([
642
457
  log
643
458
  ], GitAdapter.prototype, "streamDiffLines", null);
459
+ // Normalizes streamBlob's result (a real BlobStream in production, plain
460
+ // arrays of chunks in unit fakes) into a single stateful AsyncGenerator so
461
+ // streamContent can peek a few chunks via next() and later hand the same
462
+ // cursor to a for-await loop without losing its place.
463
+ async function* normalizeChunks(source) {
464
+ yield* source;
465
+ }
466
+ const isLfsPointer = (head) => head.length >= LFS_MAGIC.length &&
467
+ head.subarray(0, LFS_MAGIC.length).equals(LFS_MAGIC);
468
+ const peekHead = async (chunks) => {
469
+ const parts = [];
470
+ let length = 0;
471
+ while (length < LFS_MAGIC.length) {
472
+ const result = await chunks.next();
473
+ if (result.done) {
474
+ return { head: Buffer.concat(parts, length), exhausted: true };
475
+ }
476
+ parts.push(result.value);
477
+ length += result.value.length;
478
+ }
479
+ return { head: Buffer.concat(parts, length), exhausted: false };
480
+ };
481
+ const writeChunk = async (out, chunk) => {
482
+ if (!out.write(chunk)) {
483
+ await once(out, 'drain');
484
+ }
485
+ };
486
+ const forwardChunks = async (chunks, out, head, exhausted) => {
487
+ if (head.length > 0) {
488
+ await writeChunk(out, head);
489
+ }
490
+ if (!exhausted) {
491
+ for await (const chunk of chunks) {
492
+ await writeChunk(out, chunk);
493
+ }
494
+ }
495
+ out.end();
496
+ };
497
+ const assertWithinPointerCap = (length) => {
498
+ if (length > LFS_POINTER_CAP) {
499
+ throw new Error('LFS pointer exceeds expected size');
500
+ }
501
+ };
502
+ const accumulatePointer = async (chunks, head) => {
503
+ const parts = [head];
504
+ let length = head.length;
505
+ assertWithinPointerCap(length);
506
+ for await (const chunk of chunks) {
507
+ parts.push(chunk);
508
+ length += chunk.length;
509
+ assertWithinPointerCap(length);
510
+ }
511
+ return Buffer.concat(parts, length);
512
+ };
644
513
  //# sourceMappingURL=GitAdapter.js.map