sfdx-git-delta 7.2.0 → 7.4.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 (85) hide show
  1. package/README.md +18 -5
  2. package/lib/adapter/GitAdapter.d.ts +22 -13
  3. package/lib/adapter/GitAdapter.js +170 -105
  4. package/lib/adapter/GitAdapter.js.map +1 -1
  5. package/lib/adapter/ioExecutor.d.ts +5 -6
  6. package/lib/adapter/ioExecutor.js +22 -22
  7. package/lib/adapter/ioExecutor.js.map +1 -1
  8. package/lib/adapter/treeIndex.d.ts +7 -4
  9. package/lib/adapter/treeIndex.js +34 -0
  10. package/lib/adapter/treeIndex.js.map +1 -1
  11. package/lib/adapter/treeReader.d.ts +8 -0
  12. package/lib/adapter/treeReader.js +22 -0
  13. package/lib/adapter/treeReader.js.map +1 -0
  14. package/lib/adapter/tsgitErrorMap.d.ts +5 -3
  15. package/lib/adapter/tsgitErrorMap.js +48 -16
  16. package/lib/adapter/tsgitErrorMap.js.map +1 -1
  17. package/lib/commands/sgd/source/delta.d.ts +1 -0
  18. package/lib/commands/sgd/source/delta.js +5 -0
  19. package/lib/commands/sgd/source/delta.js.map +1 -1
  20. package/lib/main.js +27 -9
  21. package/lib/main.js.map +1 -1
  22. package/lib/post-processor/baseProcessor.d.ts +4 -4
  23. package/lib/post-processor/baseProcessor.js +6 -5
  24. package/lib/post-processor/baseProcessor.js.map +1 -1
  25. package/lib/post-processor/flowTranslationProcessor.d.ts +0 -3
  26. package/lib/post-processor/flowTranslationProcessor.js +1 -5
  27. package/lib/post-processor/flowTranslationProcessor.js.map +1 -1
  28. package/lib/post-processor/includeProcessor.d.ts +2 -3
  29. package/lib/post-processor/includeProcessor.js +4 -4
  30. package/lib/post-processor/includeProcessor.js.map +1 -1
  31. package/lib/post-processor/postProcessorManager.d.ts +2 -3
  32. package/lib/post-processor/postProcessorManager.js +4 -4
  33. package/lib/post-processor/postProcessorManager.js.map +1 -1
  34. package/lib/service/containedDecomposedHandler.d.ts +2 -2
  35. package/lib/service/containedDecomposedHandler.js +3 -3
  36. package/lib/service/containedDecomposedHandler.js.map +1 -1
  37. package/lib/service/customObjectHandler.js +1 -1
  38. package/lib/service/customObjectHandler.js.map +1 -1
  39. package/lib/service/diffLineInterpreter.d.ts +3 -5
  40. package/lib/service/diffLineInterpreter.js +6 -9
  41. package/lib/service/diffLineInterpreter.js.map +1 -1
  42. package/lib/service/inFileHandler.d.ts +2 -2
  43. package/lib/service/inFileHandler.js +3 -3
  44. package/lib/service/inFileHandler.js.map +1 -1
  45. package/lib/service/inFolderHandler.js +1 -1
  46. package/lib/service/inFolderHandler.js.map +1 -1
  47. package/lib/service/inResourceHandler.js +3 -2
  48. package/lib/service/inResourceHandler.js.map +1 -1
  49. package/lib/service/reportingFolderHandler.d.ts +2 -2
  50. package/lib/service/reportingFolderHandler.js +2 -2
  51. package/lib/service/reportingFolderHandler.js.map +1 -1
  52. package/lib/service/sharedFolderHandler.d.ts +2 -2
  53. package/lib/service/sharedFolderHandler.js +2 -2
  54. package/lib/service/sharedFolderHandler.js.map +1 -1
  55. package/lib/service/standardHandler.d.ts +4 -2
  56. package/lib/service/standardHandler.js +8 -3
  57. package/lib/service/standardHandler.js.map +1 -1
  58. package/lib/service/typeHandlerFactory.d.ts +6 -4
  59. package/lib/service/typeHandlerFactory.js +12 -11
  60. package/lib/service/typeHandlerFactory.js.map +1 -1
  61. package/lib/types/config.d.ts +1 -0
  62. package/lib/types/runContext.d.ts +21 -0
  63. package/lib/types/runContext.js +12 -0
  64. package/lib/types/runContext.js.map +1 -0
  65. package/lib/utils/configValidator.d.ts +4 -0
  66. package/lib/utils/configValidator.js +49 -8
  67. package/lib/utils/configValidator.js.map +1 -1
  68. package/lib/utils/errorUtils.d.ts +3 -0
  69. package/lib/utils/errorUtils.js +6 -0
  70. package/lib/utils/errorUtils.js.map +1 -1
  71. package/lib/utils/fsHelper.d.ts +3 -2
  72. package/lib/utils/fsHelper.js +9 -10
  73. package/lib/utils/fsHelper.js.map +1 -1
  74. package/lib/utils/metadataBoundaryResolver.d.ts +7 -5
  75. package/lib/utils/metadataBoundaryResolver.js +18 -21
  76. package/lib/utils/metadataBoundaryResolver.js.map +1 -1
  77. package/lib/utils/renameResolver.d.ts +2 -3
  78. package/lib/utils/renameResolver.js +2 -2
  79. package/lib/utils/renameResolver.js.map +1 -1
  80. package/lib/utils/repoGitDiff.d.ts +1 -0
  81. package/lib/utils/repoGitDiff.js +17 -1
  82. package/lib/utils/repoGitDiff.js.map +1 -1
  83. package/messages/delta.md +12 -0
  84. package/oclif.manifest.json +9 -2
  85. package/package.json +42 -27
package/README.md CHANGED
@@ -26,6 +26,9 @@
26
26
  > [!WARNING]
27
27
  > **Potentially breaking changes in v6**: Check out the [v6 migration guide](docs/migrating-to-v6.0.0.md) to see how you could be impacted by some changes in the new major `v6` version of the plugin, and how to migrate to this version.
28
28
 
29
+ > [!WARNING]
30
+ > **Breaking change for programmatic consumers**: `Config.mergeBase` is now a required field. Add `mergeBase: false` to your config to preserve today's behavior. See the [merge-base migration guide](docs/migrating-config-merge-base.md) for details.
31
+
29
32
  <!-- TABLE OF CONTENTS -->
30
33
  <details>
31
34
  <summary>Table of Contents</summary>
@@ -119,7 +122,7 @@ It's also important to implement a way to switch back to full deployment in case
119
122
 
120
123
  ### Prerequisites
121
124
 
122
- No `git` binary is required: the plugin reads the repository's git object store in-process.
125
+ No `git` binary is required: the plugin reads the repository's git object store in-process, including `--ref-format=reftable` and `--object-format=sha256` repositories (the latter isn't hosted by GitHub or GitLab today, so you're unlikely to run into one).
123
126
 
124
127
  **Node v22 or above is required**.
125
128
  To check if Salesforce CLI runs under a supported node version for SGD, run `sf --version`. You should see a node version of v22 or above to use SGD.
@@ -149,7 +152,7 @@ Generate incremental package manifest and source content
149
152
 
150
153
  ```
151
154
  USAGE
152
- $ sf sgd source delta -f <value> [--json] [--flags-dir <value>] [-t <value>] [-d] [-o <value>] [-r <value>] [-s
155
+ $ sf sgd source delta -f <value> [--json] [--flags-dir <value>] [-t <value>] [-b] [-d] [-o <value>] [-r <value>] [-s
153
156
  <value>...] [-i <value>] [-D <value>] [-n <value>] [-N <value>] [-M <value>] [-c <value>] [-W] [-a <value>]
154
157
 
155
158
  FLAGS
@@ -159,6 +162,8 @@ FLAGS
159
162
  -W, --ignore-whitespace ignore git diff whitespace (space, tab, eol) changes
160
163
  -a, --api-version=<value> salesforce metadata API version, default to sfdx-project.json
161
164
  "sourceApiVersion" attribute or latest version
165
+ -b, --merge-base diff from the merge base of --from and --to instead of --from itself (git
166
+ three-dot semantics)
162
167
  -c, --changes-manifest=<value> path to a JSON file grouping changed components by kind (add, modify,
163
168
  delete, rename); setting this flag also enables git rename detection
164
169
  -d, --generate-delta generate delta files in [--output-dir] folder
@@ -184,6 +189,8 @@ EXAMPLES
184
189
  $ sf sgd source delta --from "origin/development" --output-dir incremental
185
190
  - Build incremental manifest and source from the development branch
186
191
  $ sf sgd source delta --from "origin/development" --generate-delta --output-dir incremental
192
+ - Build incremental manifest from where the current branch diverged from main
193
+ $ sf sgd source delta --from "main" --merge-base --output-dir incremental
187
194
 
188
195
  FLAG DESCRIPTIONS
189
196
  -a, --api-version=<value>
@@ -207,7 +214,7 @@ FLAG DESCRIPTIONS
207
214
  `--source-dir force-app` does not match `nested/force-app/...`.
208
215
  ```
209
216
 
210
- _See code: [src/commands/sgd/source/delta.ts](https://github.com/scolladon/sfdx-git-delta/blob/v7.2.0/src/commands/sgd/source/delta.ts)_
217
+ _See code: [src/commands/sgd/source/delta.ts](https://github.com/scolladon/sfdx-git-delta/blob/v7.4.0/src/commands/sgd/source/delta.ts)_
211
218
  <!-- commandsstop -->
212
219
 
213
220
  ### Windows users
@@ -230,6 +237,9 @@ In CI/CD pipelines, for most of the CI/CD providers, the checkout operation fetc
230
237
  You need to fetch all the needed commits as the plugin needs access to the branch being compared.
231
238
  Example for Github action checkout [here](https://github.com/actions/checkout#fetch-all-history-for-all-tags-and-branches).
232
239
  If you use `-n` (`--include-file`) with metadata contained inside files you will need to have the full repo locally for the command to fully work.
240
+ With `--merge-base [-b]`, a shallow clone is an even sharper trap: `--from` and `--to` can each resolve to a valid commit while their histories still don't connect within the fetched depth, so SGD reports "no merge base found" even though the refs themselves are fine. Fetch enough history for the two branches to actually share an ancestor, not just enough for each ref to resolve.
241
+
242
+ GitHub Actions' `pull_request` trigger checks out a synthetic merge commit whose parent already **is** the base branch tip, so `merge-base(HEAD, main)` resolves to `main`'s tip and `--merge-base` silently becomes a no-op (`--from main --merge-base` behaves like `--from main`). If you need true divergence semantics in that context, target the PR head commit directly — `--to <head sha>` (from the `pull_request` event payload) — or check out `ref: <head sha>` instead of the default merge ref.
233
243
 
234
244
  In CI/CD pipelines, branches are not checked out locally when the repository is cloned, so you must specify the remote prefix.
235
245
  If you do not specify the remote in CI context, the git pointer check will raise an error (as the branch is not created locally).
@@ -282,12 +292,14 @@ sf sgd source delta --to develop --from main --output-dir .
282
292
  ```
283
293
 
284
294
  - **Comparing branches (from a common ancestor)**
285
- To compare the `develop` branch since its common ancestor with the `main` branch (i.e. ignoring the changes performed in the `main` branch after `develop` creation):
295
+ To compare the `develop` branch since its common ancestor with the `main` branch (i.e. ignoring the changes performed in the `main` branch after `develop` creation), use `--merge-base [-b]`:
286
296
 
287
297
  ```sh
288
- sf sgd source delta --to develop --from $(git merge-base develop main) --output-dir .
298
+ sf sgd source delta --to develop --from main --merge-base --output-dir .
289
299
  ```
290
300
 
301
+ This is equivalent to `git diff main...develop` (three-dot, common-ancestor semantics). Unlike the `$(git merge-base ...)` shell form, `--merge-base` resolves inside `--repo-dir` and needs no shell — the shell form always resolves in the current directory regardless of `--repo-dir`.
302
+
291
303
  ## Walkthrough
292
304
 
293
305
  Consider the following scenario:
@@ -609,6 +621,7 @@ import sgd from 'sfdx-git-delta'
609
621
  const work = await sgd({
610
622
  to: '', // commit sha to where the diff is done. [default : "HEAD"]
611
623
  from: '', // (required) commit sha from where the diff is done. [default : git rev-list --max-parents=0 HEAD]
624
+ mergeBase: false, // (required) resolve `from` to the merge base of `from`/`to` first. [default : false]
612
625
  output: '', // source package specific output. [default : "./output"]
613
626
  apiVersion: '', // salesforce API version. [default : latest]
614
627
  repo: '', // git repository location. [default : "."]
@@ -1,5 +1,5 @@
1
1
  import { Readable } from 'node:stream';
2
- import { type ObjectId, type Repository } from '@scolladon/tsgit';
2
+ import { type Commit, type ObjectId, type Repository } from '@scolladon/tsgit';
3
3
  import type { Config } from '../types/config.js';
4
4
  import type { FileGitRef } from '../types/git.js';
5
5
  import type { Pathspec } from '../utils/pathspec.js';
@@ -9,24 +9,36 @@ export type DiffScopeVerdict = {
9
9
  changesSeen: number;
10
10
  linesYielded: number;
11
11
  };
12
+ export type DiffSpec = Readonly<{
13
+ from: string;
14
+ to: string;
15
+ detectRenames: boolean;
16
+ ignoreWhitespace: boolean;
17
+ }>;
18
+ export type DiffRequest = Readonly<{
19
+ spec: DiffSpec;
20
+ verdict: DiffScopeVerdict;
21
+ scopes: readonly Pathspec[];
22
+ }>;
12
23
  export default class GitAdapter implements GitBlobReader {
13
- protected readonly config: Config;
24
+ private readonly key;
25
+ private readonly repoPath;
14
26
  private static instances;
15
27
  private static keyFor;
16
28
  static getInstance(config: Config): GitAdapter;
17
29
  static closeAll(): Promise<void>;
18
- protected readonly treeIndex: Map<string, TreeIndex>;
19
30
  protected readonly blobIdIndex: Map<string, Map<string, ObjectId>>;
20
31
  private repoHandle;
21
32
  private constructor();
33
+ get repositoryKey(): string;
34
+ private mapError;
22
35
  protected getRepo(): Promise<Repository>;
23
36
  close(): Promise<void>;
24
- configureRepository(): Promise<void>;
25
37
  parseRev(ref: string): Promise<string>;
26
- preBuildTreeIndex(revision: string, scopePaths: string[]): Promise<void>;
38
+ buildTreeIndex(revision: string, scopePaths: readonly string[]): Promise<TreeIndex | undefined>;
39
+ protected peelToCommit(oid: ObjectId, label: string): Promise<Commit>;
27
40
  protected indexRevision(revision: string): Promise<Map<string, ObjectId>>;
28
- protected pathExistsImpl(path: string, revision: string): boolean;
29
- pathExists(path: string, revision?: string): Promise<boolean>;
41
+ getMergeBase(from: string, to: string): Promise<string | undefined>;
30
42
  getFirstCommitRef(): Promise<string>;
31
43
  protected resolveObjectId(forRef: FileGitRef): Promise<ObjectId>;
32
44
  protected readBlobBuffer(forRef: FileGitRef): Promise<Buffer>;
@@ -40,13 +52,10 @@ export default class GitAdapter implements GitBlobReader {
40
52
  stream: Readable;
41
53
  }>;
42
54
  getStringContent(forRef: FileGitRef): Promise<string>;
43
- protected getFilesPathCached(path: string, revision: string): string[];
44
- getFilesPath(paths: string | string[], revision?: string): Promise<string[]>;
45
- listDirAtRevision(dir: string, revision: string): Promise<string[]>;
46
- grepUnderPaths(pattern: string, path: string | string[], revision?: string): Promise<string[]>;
47
- grepMatchingPathspecs(pattern: string, path: string | string[], revision?: string): Promise<string[]>;
55
+ grepUnderPaths(pattern: string, path: string | string[], revision: string): Promise<string[]>;
56
+ grepMatchingPathspecs(pattern: string, path: string | string[], revision: string): Promise<string[]>;
48
57
  private grepBlobs;
49
- streamDiffLines(verdict: DiffScopeVerdict, scopes: readonly Pathspec[]): AsyncGenerator<string>;
58
+ streamDiffLines(request: DiffRequest): AsyncGenerator<string>;
50
59
  getUnmatchedSourceScopes(verdict: DiffScopeVerdict, scopes: readonly Pathspec[]): readonly string[];
51
60
  private requestDiff;
52
61
  }
@@ -20,7 +20,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
20
20
  * (grepBlobs, shared by
21
21
  * grepUnderPaths and
22
22
  * grepMatchingPathspecs)
23
- * git config core.* -> no-op (nothing to configure)
23
+ * git merge-base <from> <to> -> repo.primitives.mergeBase
24
24
  *
25
25
  * Fidelity note: grepBlobs matches content with JS RegExp semantics, not
26
26
  * POSIX basic regex. Callers are expected to pass metacharacter-free
@@ -35,14 +35,13 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
35
35
  import { once } from 'node:events';
36
36
  import { createReadStream } from 'node:fs';
37
37
  import { readFile, stat } from 'node:fs/promises';
38
- import { join } from 'node:path/posix';
38
+ import { join, resolve } from 'node:path';
39
39
  import { PassThrough, Readable } from 'node:stream';
40
40
  import { openRepository, } from '@scolladon/tsgit';
41
41
  import { UTF8_ENCODING } from '../constant/fsConstants.js';
42
42
  import { HEAD } from '../constant/gitConstants.js';
43
- import { pushAll } from '../utils/arrayUtils.js';
44
43
  import { getErrorMessage } from '../utils/errorUtils.js';
45
- import { treatPathSep } from '../utils/fsUtils.js';
44
+ import { sanitizePath, treatPathSep } from '../utils/fsUtils.js';
46
45
  import { getLFSObjectContentPath, isLFS } from '../utils/gitLfsHelper.js';
47
46
  import { log } from '../utils/LoggingDecorator.js';
48
47
  import { Logger, lazy } from '../utils/LoggingService.js';
@@ -66,15 +65,33 @@ const IGNORE_WHITESPACE_OPTIONS = {
66
65
  const LFS_MAGIC = Buffer.from('version https://git-lfs.github.com/spec/v1\n');
67
66
  const LFS_POINTER_CAP = 1024;
68
67
  export default class GitAdapter {
69
- config;
68
+ key;
69
+ repoPath;
70
70
  static instances = new Map();
71
- static keyFor(config) {
72
- return `${config.repo}\0${config.to}`;
71
+ // Normalises the pool key the same way ConfigValidator eventually
72
+ // normalises config.repo (fsUtils#sanitizePath), so two configs that
73
+ // differ only by an unnormalised repo path (e.g. `./repo` vs `repo`)
74
+ // still resolve to one instance. ConfigValidator pools an adapter on the
75
+ // raw config.repo before _sanitizeConfig runs, so without this a single
76
+ // `--repo-dir ./repo` invocation would still allocate two instances.
77
+ // Takes the already-resolved (platform-form) path rather than resolving
78
+ // again, so the key and the repository path it is derived from can never
79
+ // drift onto two different resolutions of the same config.repo.
80
+ static keyFor(resolvedRepo) {
81
+ return sanitizePath(resolvedRepo);
73
82
  }
74
83
  static getInstance(config) {
75
- const key = GitAdapter.keyFor(config);
84
+ // Resolved to an absolute path first: tsgit's openRepository validates
85
+ // `cwd` eagerly and refuses anything relative outright, which sgd's own
86
+ // CLI default ('./') would otherwise be. This platform-form value is
87
+ // threaded straight through to repository reads (see getRepo and the
88
+ // LFS reads below); the pool key instead takes its posix-sanitized
89
+ // form, since sanitizePath's separator normalisation — safe for an
90
+ // identity key — would corrupt a win32 UNC root if reads used it too.
91
+ const resolvedRepo = resolve(config.repo);
92
+ const key = GitAdapter.keyFor(resolvedRepo);
76
93
  if (!GitAdapter.instances.has(key)) {
77
- GitAdapter.instances.set(key, new GitAdapter(config));
94
+ GitAdapter.instances.set(key, new GitAdapter(key, resolvedRepo));
78
95
  }
79
96
  return GitAdapter.instances.get(key);
80
97
  }
@@ -84,33 +101,73 @@ export default class GitAdapter {
84
101
  }
85
102
  GitAdapter.instances.clear();
86
103
  }
87
- treeIndex;
88
104
  // Per revision: repo-relative path -> blob ObjectId. The tsgit counterpart
89
- // of `git cat-file --batch` oid:path resolution.
105
+ // of `git cat-file --batch` oid:path resolution. Deterministic per
106
+ // revision and safe to share across every run against this repository —
107
+ // unlike the tree index (see buildTreeIndex), nothing here varies by
108
+ // caller-supplied scope.
90
109
  blobIdIndex;
91
110
  repoHandle = null;
92
- constructor(config) {
93
- this.config = config;
94
- this.treeIndex = new Map();
111
+ // `key` identifies the repository — the pool map key and every
112
+ // user-facing message (mapTsgitError, the debug log below) render it, so
113
+ // a repository that fails the same way twice always reads identically.
114
+ // `repoPath` is the platform-native path actually opened and read from
115
+ // (openRepository's cwd, the LFS reads) — never sanitized, since
116
+ // sanitizePath's posix-only normalisation would corrupt a win32 UNC root.
117
+ constructor(key, repoPath) {
118
+ this.key = key;
119
+ this.repoPath = repoPath;
95
120
  this.blobIdIndex = new Map();
96
121
  }
122
+ // Read by ConfigValidator to render `error.PathIsNotGit` from the exact
123
+ // same absolute value this adapter renders its own repository-refusal
124
+ // messages from (see mapError below), so the two collapse to one sentence
125
+ // through validateConfig's `new Set(errors)` dedupe instead of reporting
126
+ // the same missing repository twice in different forms.
127
+ get repositoryKey() {
128
+ return this.key;
129
+ }
130
+ // Binds the repository argument every mapTsgitError call site needs so a
131
+ // transposition between it and the adjacent `context: string` argument
132
+ // can no longer compile — mapTsgitError itself stays a pure 3-argument
133
+ // function for its own unit surface.
134
+ mapError(error, context) {
135
+ return mapTsgitError(error, context, this.key);
136
+ }
97
137
  getRepo() {
98
138
  if (!this.repoHandle) {
99
- this.repoHandle = openRepository({ cwd: this.config.repo });
139
+ // sgd only ever reads a repository (revParse, diff, tree walks,
140
+ // blobs) and never runs a verb that fires a hook or a merge driver,
141
+ // so both execution surfaces are switched off outright. The
142
+ // ownership gate is opted out of for the same reason: it would
143
+ // refuse the container-mounted checkouts README.md documents as
144
+ // supported, and tsgit cannot see the `safe.directory` those users
145
+ // already configured for git itself.
146
+ this.repoHandle = openRepository({
147
+ cwd: this.repoPath,
148
+ trust: 'always',
149
+ hooks: false,
150
+ command: false,
151
+ });
100
152
  }
101
153
  return this.repoHandle;
102
154
  }
103
155
  async close() {
104
- if (this.repoHandle) {
105
- const repo = await this.repoHandle;
156
+ if (!this.repoHandle)
157
+ return;
158
+ const handle = this.repoHandle;
159
+ // A failed open is cached like any other handle, so awaiting it here
160
+ // would rethrow the raw engine error out of main.ts's finally and
161
+ // replace the mapped error the caller already reported. There is
162
+ // nothing to dispose when the open never produced a repository.
163
+ this.repoHandle = null;
164
+ try {
165
+ const repo = await handle;
106
166
  await repo.dispose();
107
- this.repoHandle = null;
108
167
  }
109
- }
110
- async configureRepository() {
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.
168
+ catch (error) {
169
+ Logger.debug(lazy `GitAdapter.close: releasing '${this.key}' failed: ${() => getErrorMessage(error)}`);
170
+ }
114
171
  }
115
172
  async parseRev(ref) {
116
173
  try {
@@ -118,27 +175,48 @@ export default class GitAdapter {
118
175
  return await repo.revParse(ref);
119
176
  }
120
177
  catch (error) {
121
- throw mapTsgitError(error, ref);
178
+ throw this.mapError(error, ref);
122
179
  }
123
180
  }
124
- async preBuildTreeIndex(revision, scopePaths) {
125
- if (this.treeIndex.has(revision)) {
126
- return;
127
- }
181
+ // Builds and RETURNS a tree index for (revision, scopePaths) instead of
182
+ // caching it: the caller (main.ts) owns the result and threads it to
183
+ // every reader, so a builder/reader scope mismatch becomes structurally
184
+ // impossible rather than a silently-missed cache key. On failure this
185
+ // degrades exactly like the old preBuildTreeIndex did — debug-log and
186
+ // return undefined, leaving the caller with no index for that revision
187
+ // (readers treat "no index" as an empty read, never a throw).
188
+ async buildTreeIndex(revision, scopePaths) {
128
189
  try {
129
190
  const blobIds = await this.indexRevision(revision);
130
191
  const index = new TreeIndex();
131
- const scopes = scopePaths.filter(scope => !ROOT_PATHS.has(scope));
192
+ const scopes = scopePaths.filter(path => !ROOT_PATHS.has(path));
132
193
  for (const path of blobIds.keys()) {
133
194
  if (scopes.length === 0 || inScope(path, scopes)) {
134
195
  index.add(path);
135
196
  }
136
197
  }
137
- this.treeIndex.set(revision, index);
198
+ return index;
138
199
  }
139
200
  catch (error) {
140
- Logger.debug(lazy `preBuildTreeIndex: tree walk for '${revision}' failed: ${() => getErrorMessage(error)}`);
201
+ Logger.debug(lazy `buildTreeIndex: tree walk for '${revision}' failed: ${() => getErrorMessage(error)}`);
202
+ return undefined;
203
+ }
204
+ }
205
+ // revParse returns the tag OBJECT oid for annotated tags (no auto-peel),
206
+ // so follow the tag chain down to the tagged commit — matching
207
+ // `git ls-tree -r <tag>` / `git merge-base` peeling semantics. `label`
208
+ // identifies the original ref/oid for the error message (it can differ
209
+ // from `oid` itself, e.g. a revision string vs. its resolved object id).
210
+ async peelToCommit(oid, label) {
211
+ const repo = await this.getRepo();
212
+ let target = await repo.primitives.readObject(oid);
213
+ while (target.type === 'tag') {
214
+ target = await repo.primitives.readObject(target.data.object);
215
+ }
216
+ if (target.type !== 'commit') {
217
+ throw new Error(`'${label}' does not resolve to a commit`);
141
218
  }
219
+ return target;
142
220
  }
143
221
  // Flattens the full tree at `revision` once and caches path -> blob oid.
144
222
  // Shared by the tree index, blob reads, archive streaming and grep.
@@ -151,17 +229,8 @@ export default class GitAdapter {
151
229
  }
152
230
  const repo = await this.getRepo();
153
231
  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);
160
- }
161
- if (target.type !== 'commit') {
162
- throw new Error(`'${revision}' does not resolve to a commit`);
163
- }
164
- const { entries } = await repo.primitives.flattenTree(target.data.tree);
232
+ const commit = await this.peelToCommit(revisionId, revision);
233
+ const { entries } = await repo.primitives.flattenTree(commit.data.tree);
165
234
  const blobIds = new Map();
166
235
  for (const [path, entry] of entries) {
167
236
  if (BLOB_MODES.has(entry.mode)) {
@@ -171,16 +240,39 @@ export default class GitAdapter {
171
240
  this.blobIdIndex.set(revision, blobIds);
172
241
  return blobIds;
173
242
  }
174
- pathExistsImpl(path, revision) {
175
- const index = this.treeIndex.get(revision);
176
- if (!index)
177
- return false;
178
- if (ROOT_PATHS.has(path))
179
- return index.size > 0;
180
- return index.hasPath(path);
181
- }
182
- async pathExists(path, revision = this.config.to) {
183
- return this.pathExistsImpl(path, revision);
243
+ // Equivalent to `git merge-base <from> <to>`, resolved in-process via
244
+ // tsgit no local git binary needed. `from`/`to` are resolved through
245
+ // revParse the same way indexRevision does, so this method is total over
246
+ // any revision string (ref, short SHA, `HEAD~2`, …) rather than encoding
247
+ // an "already an ObjectId" precondition in a comment. tsgit's `[]` result
248
+ // (no common ancestor / unrelated histories) is a legitimate git answer,
249
+ // not an exception — surfacing it as a user-facing error is the caller's
250
+ // job (ConfigValidator), not this adapter's.
251
+ async getMergeBase(from, to) {
252
+ try {
253
+ const repo = await this.getRepo();
254
+ const [fromId, toId] = await Promise.all([
255
+ repo.revParse(from),
256
+ repo.revParse(to),
257
+ ]);
258
+ const [fromCommit, toCommit] = await Promise.all([
259
+ this.peelToCommit(fromId, from),
260
+ this.peelToCommit(toId, to),
261
+ ]);
262
+ // Criss-cross histories can legitimately have several common
263
+ // ancestors; tsgit's mergeBase primitive returns all of them. Taking
264
+ // only the first matches `git merge-base`'s own default (without
265
+ // --all, which returns just one candidate), so this is a deliberate
266
+ // choice, not an oversight.
267
+ const [base] = await repo.primitives.mergeBase([
268
+ fromCommit.id,
269
+ toCommit.id,
270
+ ]);
271
+ return base;
272
+ }
273
+ catch (error) {
274
+ throw this.mapError(error, `${from}...${to}`);
275
+ }
184
276
  }
185
277
  async getFirstCommitRef() {
186
278
  try {
@@ -198,7 +290,7 @@ export default class GitAdapter {
198
290
  return firstCommit;
199
291
  }
200
292
  catch (error) {
201
- throw mapTsgitError(error, HEAD);
293
+ throw this.mapError(error, HEAD);
202
294
  }
203
295
  }
204
296
  async resolveObjectId(forRef) {
@@ -220,12 +312,12 @@ export default class GitAdapter {
220
312
  let content = await this.readBlobBuffer(forRef);
221
313
  if (isLFS(content)) {
222
314
  const lfsPath = getLFSObjectContentPath(content);
223
- content = await readFile(join(this.config.repo, lfsPath));
315
+ content = await readFile(join(this.repoPath, lfsPath));
224
316
  }
225
317
  return content;
226
318
  }
227
319
  catch (error) {
228
- throw mapTsgitError(error, forRef.oid);
320
+ throw this.mapError(error, forRef.oid);
229
321
  }
230
322
  }
231
323
  // tsgit's Blob/BlobStream carry no size field, so the only way to know a
@@ -250,7 +342,7 @@ export default class GitAdapter {
250
342
  // The pointer itself is tiny, so the accumulated-length guard above
251
343
  // never fires for LFS-backed files — size the resolved object instead
252
344
  // and escalate oversized ones onto the streaming path.
253
- const lfsFile = join(this.config.repo, getLFSObjectContentPath(content));
345
+ const lfsFile = join(this.repoPath, getLFSObjectContentPath(content));
254
346
  const { size } = await stat(lfsFile);
255
347
  if (size > SIZE_THRESHOLD) {
256
348
  throw new EscalateToStreamingSignal(size, forRef);
@@ -286,7 +378,7 @@ export default class GitAdapter {
286
378
  async pipeLfsObject(chunks, head, out) {
287
379
  const pointer = await accumulatePointer(chunks, head);
288
380
  const lfsPath = getLFSObjectContentPath(pointer);
289
- createReadStream(join(this.config.repo, lfsPath))
381
+ createReadStream(join(this.repoPath, lfsPath))
290
382
  .on('error', (error) => out.destroy(error))
291
383
  .pipe(out);
292
384
  }
@@ -309,44 +401,18 @@ export default class GitAdapter {
309
401
  const content = await this.getBufferContent(forRef);
310
402
  return content.toString(UTF8_ENCODING);
311
403
  }
312
- getFilesPathCached(path, revision) {
313
- const index = this.treeIndex.get(revision);
314
- if (!index)
315
- return [];
316
- if (ROOT_PATHS.has(path))
317
- return index.allPaths();
318
- if (index.has(path))
319
- return [path];
320
- return index.getFilesUnder(path);
321
- }
322
- async getFilesPath(paths, revision = this.config.to) {
323
- if (typeof paths === 'string') {
324
- return this.getFilesPathCached(paths, revision);
325
- }
326
- const result = [];
327
- for (const path of paths) {
328
- pushAll(result, this.getFilesPathCached(path, revision));
329
- }
330
- return result;
331
- }
332
- async listDirAtRevision(dir, revision) {
333
- const index = this.treeIndex.get(revision);
334
- if (!index)
335
- return [];
336
- return index.listChildren(dir);
337
- }
338
404
  // Concrete-path surface: `path` comes straight off the repository (a git
339
405
  // diff path run through treatPathSep, or MetadataElement.basePath) and is
340
406
  // matched by literal directory prefix — never as a wildmatch pathspec, so
341
407
  // a metacharacter incidentally present in a real path (e.g. an object
342
408
  // folder named `Custom[1]__c`) can never be misread as a glob.
343
- async grepUnderPaths(pattern, path, revision = this.config.to) {
409
+ async grepUnderPaths(pattern, path, revision) {
344
410
  return this.grepBlobs(pattern, path, revision, buildLiteralMatcher);
345
411
  }
346
412
  // Pattern surface: `path` is an sgd-constructed pathspec (e.g.
347
413
  // `<source>/*.translation-meta.xml`) and is matched with git pathspec
348
414
  // wildmatch semantics (literal + glob).
349
- async grepMatchingPathspecs(pattern, path, revision = this.config.to) {
415
+ async grepMatchingPathspecs(pattern, path, revision) {
350
416
  return this.grepBlobs(pattern, path, revision, buildPathspecMatcher);
351
417
  }
352
418
  async grepBlobs(pattern, path, revision, buildMatcher) {
@@ -377,8 +443,9 @@ export default class GitAdapter {
377
443
  // behind `config.changesManifest` like the subprocess `-M`), and the
378
444
  // whitespace options drop whitespace-only modifications the way the
379
445
  // subprocess numstat path does.
380
- async *streamDiffLines(verdict, scopes) {
381
- const { changes } = await this.requestDiff();
446
+ async *streamDiffLines(request) {
447
+ const { spec, verdict, scopes } = request;
448
+ const { changes } = await this.requestDiff(spec);
382
449
  // git unions pathspecs (`-- . src` matches everything), so a root scope
383
450
  // must not be filtered out here even when non-root scopes are also
384
451
  // configured — the full, unfiltered source list is what `inScope`
@@ -401,52 +468,44 @@ export default class GitAdapter {
401
468
  if (hasRootScope(scopes))
402
469
  return [];
403
470
  const unmatchable = nonRootScopes(scopes);
471
+ // Stryker disable next-line ConditionalExpression,EqualityOperator -- equivalent: `.length` is never negative so `>= 0` is a tautology, and forcing this operand true only changes the ternary when unmatchable.length === 0 — in which case `unmatchable` is itself [], deep-equal to the `: []` branch it would take instead
404
472
  return unmatchable.length > 0 &&
405
473
  verdict.changesSeen > 0 &&
406
474
  verdict.linesYielded === 0
407
475
  ? unmatchable
408
476
  : [];
409
477
  }
410
- async requestDiff() {
478
+ async requestDiff(spec) {
411
479
  try {
412
480
  const repo = await this.getRepo();
413
481
  return await repo.diff({
414
- from: this.config.from,
415
- to: this.config.to,
482
+ from: spec.from,
483
+ to: spec.to,
416
484
  recursive: true,
417
- detectRenames: Boolean(this.config.changesManifest),
418
- ...(this.config.ignoreWhitespace ? IGNORE_WHITESPACE_OPTIONS : {}),
485
+ detectRenames: spec.detectRenames,
486
+ ...(spec.ignoreWhitespace ? IGNORE_WHITESPACE_OPTIONS : {}),
419
487
  });
420
488
  }
421
489
  catch (error) {
422
- throw mapTsgitError(error, `${this.config.from}..${this.config.to}`);
490
+ throw this.mapError(error, `${spec.from}..${spec.to}`);
423
491
  }
424
492
  }
425
493
  }
426
- __decorate([
427
- log
428
- ], GitAdapter.prototype, "configureRepository", null);
429
494
  __decorate([
430
495
  log
431
496
  ], GitAdapter.prototype, "parseRev", null);
432
497
  __decorate([
433
498
  log
434
- ], GitAdapter.prototype, "preBuildTreeIndex", null);
499
+ ], GitAdapter.prototype, "buildTreeIndex", null);
435
500
  __decorate([
436
501
  log
437
- ], GitAdapter.prototype, "pathExists", null);
502
+ ], GitAdapter.prototype, "getMergeBase", null);
438
503
  __decorate([
439
504
  log
440
505
  ], GitAdapter.prototype, "getFirstCommitRef", null);
441
506
  __decorate([
442
507
  log
443
508
  ], GitAdapter.prototype, "getStringContent", null);
444
- __decorate([
445
- log
446
- ], GitAdapter.prototype, "getFilesPath", null);
447
- __decorate([
448
- log
449
- ], GitAdapter.prototype, "listDirAtRevision", null);
450
509
  __decorate([
451
510
  log
452
511
  ], GitAdapter.prototype, "grepUnderPaths", null);
@@ -463,14 +522,18 @@ __decorate([
463
522
  async function* normalizeChunks(source) {
464
523
  yield* source;
465
524
  }
466
- const isLfsPointer = (head) => head.length >= LFS_MAGIC.length &&
525
+ const isLfsPointer = (head) =>
526
+ // Stryker disable next-line ConditionalExpression -- equivalent: the length guard is redundant. Buffer#subarray clamps rather than throwing when the end index exceeds the buffer, and Buffer#equals is false for operands of differing length, so the comparison alone already returns false for any head shorter than the magic
527
+ head.length >= LFS_MAGIC.length &&
467
528
  head.subarray(0, LFS_MAGIC.length).equals(LFS_MAGIC);
468
529
  const peekHead = async (chunks) => {
469
530
  const parts = [];
470
531
  let length = 0;
532
+ // Stryker disable next-line EqualityOperator -- equivalent: `<=` only shifts where the peek stops versus what the caller's remaining-chunk iteration picks up; the same total bytes reach forwardChunks/accumulatePointer either way, so the streamed output is byte-identical
471
533
  while (length < LFS_MAGIC.length) {
472
534
  const result = await chunks.next();
473
535
  if (result.done) {
536
+ // Stryker disable next-line BooleanLiteral -- equivalent: `exhausted` only gates whether forwardChunks re-enters a `for await` over the generator, and per the async-generator protocol a generator that has returned done keeps returning done — so re-entering when already exhausted is a guaranteed zero-iteration no-op
474
537
  return { head: Buffer.concat(parts, length), exhausted: true };
475
538
  }
476
539
  parts.push(result.value);
@@ -484,9 +547,11 @@ const writeChunk = async (out, chunk) => {
484
547
  }
485
548
  };
486
549
  const forwardChunks = async (chunks, out, head, exhausted) => {
550
+ // Stryker disable next-line ConditionalExpression,EqualityOperator -- equivalent: writing a zero-length Buffer to a PassThrough is a no-op (no 'data' event, readableLength/writableLength unchanged, write() returns true), so calling writeChunk unconditionally when head is empty is indistinguishable from skipping it
487
551
  if (head.length > 0) {
488
552
  await writeChunk(out, head);
489
553
  }
554
+ // Stryker disable next-line ConditionalExpression -- equivalent: same reasoning as the `exhausted: true` return in peekHead — entering this loop on an already-exhausted generator iterates zero times
490
555
  if (!exhausted) {
491
556
  for await (const chunk of chunks) {
492
557
  await writeChunk(out, chunk);