sfdx-git-delta 7.0.1 → 7.1.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 (34) hide show
  1. package/README.md +2 -2
  2. package/lib/adapter/GitAdapter.d.ts +15 -62
  3. package/lib/adapter/GitAdapter.js +376 -485
  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 +4 -3
  8. package/lib/adapter/ioExecutor.js +22 -14
  9. package/lib/adapter/ioExecutor.js.map +1 -1
  10. package/lib/adapter/tsgitErrorMap.d.ts +7 -0
  11. package/lib/adapter/tsgitErrorMap.js +32 -0
  12. package/lib/adapter/tsgitErrorMap.js.map +1 -0
  13. package/lib/constant/gitConstants.d.ts +0 -2
  14. package/lib/constant/gitConstants.js +0 -8
  15. package/lib/constant/gitConstants.js.map +1 -1
  16. package/lib/constant/metadataConstants.d.ts +1 -0
  17. package/lib/constant/metadataConstants.js +1 -0
  18. package/lib/constant/metadataConstants.js.map +1 -1
  19. package/lib/main.js +1 -1
  20. package/lib/main.js.map +1 -1
  21. package/lib/post-processor/flowTranslationProcessor.js +2 -3
  22. package/lib/post-processor/flowTranslationProcessor.js.map +1 -1
  23. package/lib/service/inFileHandler.js +4 -3
  24. package/lib/service/inFileHandler.js.map +1 -1
  25. package/lib/utils/metadataDiff/index.d.ts +0 -2
  26. package/lib/utils/metadataDiff/index.js.map +1 -1
  27. package/lib/utils/metadataDiff/streamingDiff.d.ts +1 -3
  28. package/lib/utils/metadataDiff/streamingDiff.js +16 -54
  29. package/lib/utils/metadataDiff/streamingDiff.js.map +1 -1
  30. package/oclif.manifest.json +1 -1
  31. package/package.json +22 -15
  32. package/lib/adapter/gitBatchCatFile.d.ts +0 -42
  33. package/lib/adapter/gitBatchCatFile.js +0 -203
  34. package/lib/adapter/gitBatchCatFile.js.map +0 -1
@@ -4,32 +4,64 @@ 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
+ * git config core.* -> no-op (nothing to configure)
21
+ *
22
+ * Fidelity note: gitGrep matches with JS RegExp semantics, not POSIX basic
23
+ * regex. Callers are expected to pass metacharacter-free literals so the two
24
+ * semantics agree; the known pattern set is pinned by
25
+ * gitGrepPatternInventory.test.ts (new callers must extend that inventory).
26
+ */
27
+ import { once } from 'node:events';
8
28
  import { createReadStream } from 'node:fs';
9
- import { readFile } from 'node:fs/promises';
29
+ import { readFile, stat } from 'node:fs/promises';
10
30
  import { join } from 'node:path/posix';
11
- import { createInterface } from 'node:readline';
12
- import { PassThrough } from 'node:stream';
31
+ import { PassThrough, Readable } from 'node:stream';
32
+ import { openRepository, toSimilarityPercent, } from '@scolladon/tsgit';
13
33
  import { TAB } from '../constant/cliConstants.js';
14
34
  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';
35
+ import { ADDITION, DELETION, HEAD, MODIFICATION, RENAMED, } from '../constant/gitConstants.js';
16
36
  import { pushAll } from '../utils/arrayUtils.js';
17
37
  import { getErrorMessage } from '../utils/errorUtils.js';
18
38
  import { treatPathSep } from '../utils/fsUtils.js';
19
39
  import { getLFSObjectContentPath, isLFS } from '../utils/gitLfsHelper.js';
20
40
  import { log } from '../utils/LoggingDecorator.js';
21
41
  import { Logger, lazy } from '../utils/LoggingService.js';
22
- import { GitBatchCatFile } from './gitBatchCatFile.js';
42
+ import { EscalateToStreamingSignal, SIZE_THRESHOLD, } from './gitBlobReader.js';
23
43
  import { TreeIndex } from './treeIndex.js';
24
- const LFS_MAGIC = Buffer.from('version https://git-lfs.github.com/spec/v1\n');
25
- const EOL = /\r?\n/;
44
+ import { mapTsgitError } from './tsgitErrorMap.js';
26
45
  const ROOT_PATHS = new Set(['', '.', './']);
46
+ // walkTree yields directories and gitlinks too; only blob-bearing modes
47
+ // belong in the path -> blob id index (ls-tree -r parity).
48
+ const BLOB_MODES = new Set(['100644', '100755', '120000']);
49
+ const GITLINK_MODE = '160000';
50
+ // Maps sgd's whitespace-ignoring diff request onto tsgit's data-mode
51
+ // whitespace knobs: whitespace-only modifications drop out of the TreeDiff.
52
+ const IGNORE_WHITESPACE_OPTIONS = {
53
+ ignoreWhitespace: 'all',
54
+ ignoreBlankLines: true,
55
+ };
56
+ // Streaming LFS-pointer detection for streamContent: the literal magic
57
+ // prefix released LFS pointer files start with, and a generous upper
58
+ // bound on pointer body size (real pointers are ~130 bytes; this guards
59
+ // against a corrupt/oversized "pointer" masquerading as one).
60
+ const LFS_MAGIC = Buffer.from('version https://git-lfs.github.com/spec/v1\n');
61
+ const LFS_POINTER_CAP = 1024;
27
62
  export default class GitAdapter {
28
63
  config;
29
64
  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
65
  static keyFor(config) {
34
66
  return `${config.repo}\0${config.to}`;
35
67
  }
@@ -40,198 +72,98 @@ export default class GitAdapter {
40
72
  }
41
73
  return GitAdapter.instances.get(key);
42
74
  }
75
+ static async closeAll() {
76
+ for (const instance of GitAdapter.instances.values()) {
77
+ await instance.close();
78
+ }
79
+ GitAdapter.instances.clear();
80
+ }
43
81
  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;
82
+ // Per revision: repo-relative path -> blob ObjectId. The tsgit counterpart
83
+ // of `git cat-file --batch` oid:path resolution.
84
+ blobIdIndex;
85
+ repoHandle = null;
59
86
  constructor(config) {
60
87
  this.config = config;
61
88
  this.treeIndex = new Map();
89
+ this.blobIdIndex = new Map();
62
90
  }
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
- });
91
+ getRepo() {
92
+ if (!this.repoHandle) {
93
+ this.repoHandle = openRepository({ cwd: this.config.repo });
75
94
  }
76
- return this.batchCatFile;
95
+ return this.repoHandle;
77
96
  }
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
+ async close() {
98
+ if (this.repoHandle) {
99
+ const repo = await this.repoHandle;
100
+ await repo.dispose();
101
+ this.repoHandle = null;
85
102
  }
86
- this.streamingChildren.length = 0;
87
- }
88
- static closeAll() {
89
- for (const instance of GitAdapter.instances.values()) {
90
- instance.closeBatchProcess();
91
- }
92
- GitAdapter.instances.clear();
93
103
  }
94
104
  async configureRepository() {
95
- await this._gitBuffered(['config', 'core.longpaths', 'true']);
96
- await this._gitBuffered(['config', 'core.quotepath', 'off']);
105
+ // core.longpaths / core.quotepath exist to fix `git` CLI output and
106
+ // Windows path handling in subprocesses. tsgit reads the object store
107
+ // directly: nothing to configure.
97
108
  }
98
109
  async parseRev(ref) {
99
- return await this._gitBuffered(['rev-parse', '--verify', ref]);
110
+ try {
111
+ const repo = await this.getRepo();
112
+ return await repo.revParse(ref);
113
+ }
114
+ catch (error) {
115
+ throw mapTsgitError(error, ref);
116
+ }
100
117
  }
101
118
  async preBuildTreeIndex(revision, scopePaths) {
102
119
  if (this.treeIndex.has(revision)) {
103
120
  return;
104
121
  }
105
122
  try {
106
- const args = ['ls-tree', '--name-only', '-r', revision];
107
- if (scopePaths.length > 0) {
108
- args.push('--', ...scopePaths);
109
- }
123
+ const blobIds = await this.indexRevision(revision);
110
124
  const index = new TreeIndex();
111
- for await (const line of this._spawnLines(args)) {
112
- if (line)
113
- index.add(treatPathSep(line));
125
+ const scopes = scopePaths.filter(scope => !ROOT_PATHS.has(scope));
126
+ for (const path of blobIds.keys()) {
127
+ if (scopes.length === 0 || inScope(path, scopes)) {
128
+ index.add(path);
129
+ }
114
130
  }
115
131
  this.treeIndex.set(revision, index);
116
132
  }
117
133
  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)}`);
122
- }
123
- }
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);
134
+ Logger.debug(lazy `preBuildTreeIndex: tree walk for '${revision}' failed: ${() => getErrorMessage(error)}`);
135
+ }
136
+ }
137
+ // Flattens the full tree at `revision` once and caches path -> blob oid.
138
+ // Shared by the tree index, blob reads, archive streaming and grep.
139
+ // flattenTree is the bulk traversal path (one call, no per-entry yields);
140
+ // it takes a tree oid, so the commit is peeled first.
141
+ async indexRevision(revision) {
142
+ const cached = this.blobIdIndex.get(revision);
143
+ if (cached) {
144
+ return cached;
145
+ }
146
+ const repo = await this.getRepo();
147
+ const revisionId = await repo.revParse(revision);
148
+ // revParse returns the tag OBJECT oid for annotated tags (no auto-peel),
149
+ // so follow the tag chain down to the tagged commit before reading its
150
+ // tree — matching `git ls-tree -r <tag>` semantics.
151
+ let target = await repo.primitives.readObject(revisionId);
152
+ while (target.type === 'tag') {
153
+ target = await repo.primitives.readObject(target.data.object);
154
+ }
155
+ if (target.type !== 'commit') {
156
+ throw new Error(`'${revision}' does not resolve to a commit`);
157
+ }
158
+ const { entries } = await repo.primitives.flattenTree(target.data.tree);
159
+ const blobIds = new Map();
160
+ for (const [path, entry] of entries) {
161
+ if (BLOB_MODES.has(entry.mode)) {
162
+ blobIds.set(treatPathSep(path), entry.id);
168
163
  }
169
164
  }
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();
175
- }
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);
212
- }
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
- });
165
+ this.blobIdIndex.set(revision, blobIds);
166
+ return blobIds;
235
167
  }
236
168
  pathExistsImpl(path, revision) {
237
169
  const index = this.treeIndex.get(revision);
@@ -245,197 +177,129 @@ export default class GitAdapter {
245
177
  return this.pathExistsImpl(path, revision);
246
178
  }
247
179
  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));
180
+ try {
181
+ const repo = await this.getRepo();
182
+ const head = await repo.revParse(HEAD);
183
+ let firstCommit = head;
184
+ for await (const commit of repo.primitives.walkCommits({
185
+ from: [head],
186
+ })) {
187
+ if (commit.data.parents.length === 0) {
188
+ firstCommit = commit.id;
189
+ break;
190
+ }
191
+ }
192
+ return firstCommit;
193
+ }
194
+ catch (error) {
195
+ throw mapTsgitError(error, HEAD);
255
196
  }
256
- return content;
257
197
  }
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));
198
+ async resolveObjectId(forRef) {
199
+ const blobIds = await this.indexRevision(forRef.oid);
200
+ const blobId = blobIds.get(treatPathSep(forRef.path));
201
+ if (!blobId) {
202
+ throw new Error(`Path '${forRef.path}' not found at '${forRef.oid}'`);
263
203
  }
264
- return content;
204
+ return blobId;
265
205
  }
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}`);
279
- }
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);
206
+ async readBlobBuffer(forRef) {
207
+ const repo = await this.getRepo();
208
+ const blobId = await this.resolveObjectId(forRef);
209
+ const blob = await repo.primitives.readBlob(blobId);
210
+ return Buffer.from(blob.content);
211
+ }
212
+ async getBufferContent(forRef) {
292
213
  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 };
214
+ let content = await this.readBlobBuffer(forRef);
215
+ if (isLFS(content)) {
216
+ const lfsPath = getLFSObjectContentPath(content);
217
+ content = await readFile(join(this.config.repo, lfsPath));
218
+ }
219
+ return content;
220
+ }
221
+ catch (error) {
222
+ throw mapTsgitError(error, forRef.oid);
223
+ }
224
+ }
225
+ // tsgit's Blob/BlobStream carry no size field, so the only way to know a
226
+ // blob is oversized is to accumulate streamBlob chunks and watch the
227
+ // running total: crossing SIZE_THRESHOLD escalates the caller onto the
228
+ // dedicated streamContent path instead of materializing further.
229
+ async getBufferContentOrEscalate(forRef) {
230
+ const repo = await this.getRepo();
231
+ const blobId = await this.resolveObjectId(forRef);
232
+ const blobStream = await repo.primitives.streamBlob(blobId);
233
+ const parts = [];
234
+ let length = 0;
235
+ for await (const chunk of blobStream) {
236
+ parts.push(chunk);
237
+ length += chunk.length;
238
+ if (length > SIZE_THRESHOLD) {
239
+ throw new EscalateToStreamingSignal(length, forRef);
299
240
  }
300
241
  }
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();
242
+ let content = Buffer.concat(parts, length);
243
+ if (isLFS(content)) {
244
+ // The pointer itself is tiny, so the accumulated-length guard above
245
+ // never fires for LFS-backed files — size the resolved object instead
246
+ // and escalate oversized ones onto the streaming path.
247
+ const lfsFile = join(this.config.repo, getLFSObjectContentPath(content));
248
+ const { size } = await stat(lfsFile);
249
+ if (size > SIZE_THRESHOLD) {
250
+ throw new EscalateToStreamingSignal(size, forRef);
251
+ }
252
+ content = await readFile(lfsFile);
306
253
  }
254
+ return content;
307
255
  }
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
- */
256
+ // Peeks the first LFS_MAGIC.length bytes off the pull-based streamBlob
257
+ // iterator: a match hands off to the resolved LFS object file, otherwise
258
+ // the peeked head and every remaining chunk are forwarded as-is. Backed
259
+ // by the async iterator's own pull semantics, so no pause/resume is
260
+ // needed to respect backpressure — only out.write()'s own signal.
314
261
  streamContent(forRef) {
315
262
  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);
263
+ this.pipeBlobContent(forRef, out).catch((error) => {
264
+ out.destroy(error instanceof Error ? error : new Error(getErrorMessage(error)));
265
+ });
328
266
  return out;
329
267
  }
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
- });
268
+ async pipeBlobContent(forRef, out) {
269
+ const repo = await this.getRepo();
270
+ const blobId = await this.resolveObjectId(forRef);
271
+ const blobStream = await repo.primitives.streamBlob(blobId);
272
+ const chunks = normalizeChunks(blobStream);
273
+ const { head, exhausted } = await peekHead(chunks);
274
+ if (isLfsPointer(head)) {
275
+ await this.pipeLfsObject(chunks, head, out);
276
+ return;
277
+ }
278
+ await forwardChunks(chunks, out, head, exhausted);
394
279
  }
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();
280
+ async pipeLfsObject(chunks, head, out) {
281
+ const pointer = await accumulatePointer(chunks, head);
282
+ const lfsPath = getLFSObjectContentPath(pointer);
283
+ createReadStream(join(this.config.repo, lfsPath))
284
+ .on('error', (error) => out.destroy(error))
285
+ .pipe(out);
286
+ }
287
+ async *streamArchive(path, revision) {
288
+ const repo = await this.getRepo();
289
+ const blobIds = await this.indexRevision(revision);
290
+ for (const [filePath, blobId] of blobIds) {
291
+ if (!inScope(filePath, [path]))
292
+ continue;
293
+ const blob = await repo.primitives.streamBlob(blobId);
294
+ yield {
295
+ path: filePath,
296
+ stream: Readable.from(blob, { objectMode: false }),
297
+ };
298
+ }
437
299
  }
438
300
  async getStringContent(forRef) {
301
+ // getBufferContent already maps raw tsgit errors — mapping exactly once
302
+ // keeps the released error shapes intact (no double-wrapped messages).
439
303
  const content = await this.getBufferContent(forRef);
440
304
  return content.toString(UTF8_ENCODING);
441
305
  }
@@ -445,7 +309,6 @@ export default class GitAdapter {
445
309
  return [];
446
310
  if (ROOT_PATHS.has(path))
447
311
  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
312
  if (index.has(path))
450
313
  return [path];
451
314
  return index.getFilesUnder(path);
@@ -468,147 +331,53 @@ export default class GitAdapter {
468
331
  }
469
332
  async gitGrep(pattern, path, revision = this.config.to) {
470
333
  try {
334
+ const repo = await this.getRepo();
471
335
  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)));
336
+ const blobIds = await this.indexRevision(revision);
337
+ const matcher = new RegExp(pattern);
338
+ const matchesPathspec = buildPathspecMatcher(paths);
339
+ const matches = [];
340
+ for (const [filePath, blobId] of blobIds) {
341
+ if (!matchesPathspec(filePath))
342
+ continue;
343
+ const blob = await repo.primitives.readBlob(blobId);
344
+ if (matcher.test(Buffer.from(blob.content).toString(UTF8_ENCODING))) {
345
+ matches.push(filePath);
346
+ }
347
+ }
348
+ return matches;
484
349
  }
485
350
  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)}`);
351
+ Logger.debug(lazy `gitGrep: grep for '${pattern}' in '${path}' at '${revision}' failed: ${() => getErrorMessage(error)}`);
490
352
  return [];
491
353
  }
492
354
  }
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.
355
+ // One facade diff call carries the whole subprocess contract: `recursive`
356
+ // is `git diff -r`, `detectRenames` is `-M` (similarity-based, gated
357
+ // behind `config.changesManifest` like the subprocess `-M`), and the
358
+ // whitespace options drop whitespace-only modifications the way the
359
+ // subprocess numstat path does.
514
360
  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)) {
546
- yield line;
547
- }
361
+ const { changes } = await this.requestDiff();
362
+ const scopes = this.config.source.filter(scope => !ROOT_PATHS.has(scope));
363
+ for (const change of changes) {
364
+ yield* toDiffLines(change, scopes);
548
365
  }
549
366
  }
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`)));
585
- }
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}`));
367
+ async requestDiff() {
368
+ try {
369
+ const repo = await this.getRepo();
370
+ return await repo.diff({
371
+ from: this.config.from,
372
+ to: this.config.to,
373
+ recursive: true,
374
+ detectRenames: Boolean(this.config.changesManifest),
375
+ ...(this.config.ignoreWhitespace ? IGNORE_WHITESPACE_OPTIONS : {}),
376
+ });
377
+ }
378
+ catch (error) {
379
+ throw mapTsgitError(error, `${this.config.from}..${this.config.to}`);
610
380
  }
611
- return lines;
612
381
  }
613
382
  }
614
383
  __decorate([
@@ -641,4 +410,126 @@ __decorate([
641
410
  __decorate([
642
411
  log
643
412
  ], GitAdapter.prototype, "streamDiffLines", null);
413
+ // Normalizes streamBlob's result (a real BlobStream in production, plain
414
+ // arrays of chunks in unit fakes) into a single stateful AsyncGenerator so
415
+ // streamContent can peek a few chunks via next() and later hand the same
416
+ // cursor to a for-await loop without losing its place.
417
+ async function* normalizeChunks(source) {
418
+ yield* source;
419
+ }
420
+ const isLfsPointer = (head) => head.length >= LFS_MAGIC.length &&
421
+ head.subarray(0, LFS_MAGIC.length).equals(LFS_MAGIC);
422
+ const peekHead = async (chunks) => {
423
+ const parts = [];
424
+ let length = 0;
425
+ while (length < LFS_MAGIC.length) {
426
+ const result = await chunks.next();
427
+ if (result.done) {
428
+ return { head: Buffer.concat(parts, length), exhausted: true };
429
+ }
430
+ parts.push(result.value);
431
+ length += result.value.length;
432
+ }
433
+ return { head: Buffer.concat(parts, length), exhausted: false };
434
+ };
435
+ const writeChunk = async (out, chunk) => {
436
+ if (!out.write(chunk)) {
437
+ await once(out, 'drain');
438
+ }
439
+ };
440
+ const forwardChunks = async (chunks, out, head, exhausted) => {
441
+ if (head.length > 0) {
442
+ await writeChunk(out, head);
443
+ }
444
+ if (!exhausted) {
445
+ for await (const chunk of chunks) {
446
+ await writeChunk(out, chunk);
447
+ }
448
+ }
449
+ out.end();
450
+ };
451
+ const assertWithinPointerCap = (length) => {
452
+ if (length > LFS_POINTER_CAP) {
453
+ throw new Error('LFS pointer exceeds expected size');
454
+ }
455
+ };
456
+ const accumulatePointer = async (chunks, head) => {
457
+ const parts = [head];
458
+ let length = head.length;
459
+ assertWithinPointerCap(length);
460
+ for await (const chunk of chunks) {
461
+ parts.push(chunk);
462
+ length += chunk.length;
463
+ assertWithinPointerCap(length);
464
+ }
465
+ return Buffer.concat(parts, length);
466
+ };
467
+ const inScope = (path, scopes) => scopes.some(scope => ROOT_PATHS.has(scope) || path === scope || path.startsWith(`${scope}/`));
468
+ const keepSide = (mode, path, scopes) => mode !== GITLINK_MODE && (scopes.length === 0 || inScope(path, scopes));
469
+ // git prints rename similarity as a zero-padded three-digit percent (R087).
470
+ const similarityPercent = (similarity) => String(toSimilarityPercent(similarity.score)).padStart(3, '0');
471
+ // The facade diff takes no pathspec, so `-- <source>` scoping is replicated
472
+ // per side. Gitlink changes are skipped (submodule pointer moves are not
473
+ // deployable metadata) and `type-change`/`copy` entries are dropped for
474
+ // parity with the subprocess `--diff-filter=AMD(R)`. A rename with only one
475
+ // side in scope degrades to that side's A/D line, matching what the
476
+ // subprocess pathspec does to a broken rename pair.
477
+ function* toDiffLines(change, scopes) {
478
+ switch (change.type) {
479
+ case 'add':
480
+ if (keepSide(change.newMode, change.newPath, scopes)) {
481
+ yield `${ADDITION}${TAB}${treatPathSep(change.newPath)}`;
482
+ }
483
+ break;
484
+ case 'delete':
485
+ if (keepSide(change.oldMode, change.oldPath, scopes)) {
486
+ yield `${DELETION}${TAB}${treatPathSep(change.oldPath)}`;
487
+ }
488
+ break;
489
+ case 'modify':
490
+ if (change.oldMode !== GITLINK_MODE &&
491
+ keepSide(change.newMode, change.path, scopes)) {
492
+ yield `${MODIFICATION}${TAB}${treatPathSep(change.path)}`;
493
+ }
494
+ break;
495
+ case 'rename': {
496
+ const oldKept = keepSide(change.oldMode, change.oldPath, scopes);
497
+ const newKept = keepSide(change.newMode, change.newPath, scopes);
498
+ if (oldKept && newKept) {
499
+ yield `${RENAMED}${similarityPercent(change.similarity)}${TAB}${treatPathSep(change.oldPath)}${TAB}${treatPathSep(change.newPath)}`;
500
+ }
501
+ else if (newKept) {
502
+ yield `${ADDITION}${TAB}${treatPathSep(change.newPath)}`;
503
+ }
504
+ else if (oldKept) {
505
+ yield `${DELETION}${TAB}${treatPathSep(change.oldPath)}`;
506
+ }
507
+ break;
508
+ }
509
+ }
510
+ }
511
+ const GLOB_CHARS = /[*?[]/;
512
+ const REGEXP_SPECIALS = /[.+^${}()|\\\]]/g;
513
+ // Git pathspec semantics: a literal pathspec matches by directory prefix; a
514
+ // pathspec containing wildcards uses wildmatch where `*` also crosses `/`
515
+ // (no `:(glob)` magic). Callers mix both shapes (e.g. flow translations use
516
+ // `<source>/*.translation-meta.xml`).
517
+ const buildPathspecMatcher = (specs) => {
518
+ // Git normalizes leading `./` (and the `.//*` shape produced by the
519
+ // default `./` source dir) away before matching; repo paths never carry
520
+ // either prefix.
521
+ const normalized = specs.map(spec => spec.replace(/^(\.\/)+/, '').replace(/^\/+/, ''));
522
+ const literals = normalized.filter(spec => !GLOB_CHARS.test(spec));
523
+ const globs = normalized
524
+ .filter(spec => GLOB_CHARS.test(spec))
525
+ .map(spec => {
526
+ const escaped = spec
527
+ .replace(REGEXP_SPECIALS, '\\$&')
528
+ .replace(/\*/g, '.*')
529
+ .replace(/\?/g, '.');
530
+ return new RegExp(`^${escaped}$`);
531
+ });
532
+ return (path) => (literals.length > 0 && inScope(path, literals)) ||
533
+ globs.some(glob => glob.test(path));
534
+ };
644
535
  //# sourceMappingURL=GitAdapter.js.map