@lzehrung/codegraph 2.3.19 → 2.3.21

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 (57) hide show
  1. package/codegraph-skill/codegraph/SKILL.md +2 -1
  2. package/dist/agent/session.d.ts +2 -2
  3. package/dist/agent/session.d.ts.map +1 -1
  4. package/dist/agent/session.js +13 -9
  5. package/dist/agent/session.js.map +1 -1
  6. package/dist/bin/cli.js +36849 -37670
  7. package/dist/bin/queryIndexWorker.js +28 -5
  8. package/dist/cli/index.d.ts.map +1 -1
  9. package/dist/cli/index.js +12 -5
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/cli/invocationContext.d.ts.map +1 -1
  12. package/dist/cli/invocationContext.js +94 -31
  13. package/dist/cli/invocationContext.js.map +1 -1
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +0 -1
  17. package/dist/config.js.map +1 -1
  18. package/dist/indexer/build-cache/manifest.d.ts +2 -2
  19. package/dist/indexer/build-cache/manifest.d.ts.map +1 -1
  20. package/dist/indexer/build-cache/manifest.js +8 -33
  21. package/dist/indexer/build-cache/manifest.js.map +1 -1
  22. package/dist/indexer/build-index.d.ts.map +1 -1
  23. package/dist/indexer/build-index.js +134 -56
  24. package/dist/indexer/build-index.js.map +1 -1
  25. package/dist/indexer/build-manifest.d.ts +8 -0
  26. package/dist/indexer/build-manifest.d.ts.map +1 -1
  27. package/dist/indexer/build-manifest.js +20 -2
  28. package/dist/indexer/build-manifest.js.map +1 -1
  29. package/dist/indexer/finalize.d.ts +2 -1
  30. package/dist/indexer/finalize.d.ts.map +1 -1
  31. package/dist/indexer/finalize.js +14 -6
  32. package/dist/indexer/finalize.js.map +1 -1
  33. package/dist/indexer/incremental-plan.d.ts +3 -2
  34. package/dist/indexer/incremental-plan.d.ts.map +1 -1
  35. package/dist/indexer/incremental-plan.js +8 -4
  36. package/dist/indexer/incremental-plan.js.map +1 -1
  37. package/dist/indexer/types.d.ts +12 -2
  38. package/dist/indexer/types.d.ts.map +1 -1
  39. package/dist/lifecycle/manifest.d.ts.map +1 -1
  40. package/dist/lifecycle/manifest.js +82 -13
  41. package/dist/lifecycle/manifest.js.map +1 -1
  42. package/dist/util/git.d.ts +13 -10
  43. package/dist/util/git.d.ts.map +1 -1
  44. package/dist/util/git.js +29 -25
  45. package/dist/util/git.js.map +1 -1
  46. package/dist/util/paths.d.ts +2 -2
  47. package/dist/util/paths.d.ts.map +1 -1
  48. package/dist/util/paths.js +27 -7
  49. package/dist/util/paths.js.map +1 -1
  50. package/dist/util/projectFiles/definitions.d.ts +19 -0
  51. package/dist/util/projectFiles/definitions.d.ts.map +1 -1
  52. package/dist/util/projectFiles/definitions.js.map +1 -1
  53. package/dist/util/projectFiles.d.ts +80 -22
  54. package/dist/util/projectFiles.d.ts.map +1 -1
  55. package/dist/util/projectFiles.js +319 -38
  56. package/dist/util/projectFiles.js.map +1 -1
  57. package/package.json +3 -3
@@ -12,7 +12,8 @@ export { isRelativePathInside, matchesDiscoveryGlob } from "./discoveryPath.js";
12
12
  import { PROJECT_FILE_DEFINITIONS, } from "./projectFiles/definitions.js";
13
13
  import { trimToNull } from "./projectFiles/parsers.js";
14
14
  import { mapLimitSemaphore } from "./concurrency.js";
15
- import { getGitRepositoryRoot, isGitProjectRootIgnored, isGitRepo, isGitTimeoutError, listGitExcludeFiles, listGitStageSpecialPaths, listTrackedFiles, listUntrackedFiles, } from "./git.js";
15
+ import { CODEGRAPH_CONFIG_FILE } from "../config.js";
16
+ import { createGitDiscoveryCache, getGitRepositoryRoot, isGitProjectRootIgnored, isGitRepo, isGitTimeoutError, listGitExcludeFiles, listGitStageSpecialPaths, listGitSubmoduleDirectories, listTrackedFiles, listUntrackedFiles, } from "./git.js";
16
17
  export const DEFAULT_PROJECT_FILE_IGNORES = [
17
18
  "**/node_modules/**",
18
19
  "**/.git/**",
@@ -100,6 +101,9 @@ export const DEFAULT_PROJECT_PATTERNS = [
100
101
  ...DEFAULT_PROJECT_MANIFESTS.map((name) => `**/${name}`),
101
102
  ];
102
103
  const REALPATH_FILTER_CONCURRENCY = 64;
104
+ export function createProjectDiscoveryContext(projectRoot) {
105
+ return new DiscoveryContext(projectRoot);
106
+ }
103
107
  function emitDiscoveryActivity(report, activity, current = 0, total = 0) {
104
108
  report?.({ activity, current, total });
105
109
  }
@@ -121,6 +125,35 @@ function isPresent(value) {
121
125
  function normalizeGlobPattern(globPattern) {
122
126
  return globPattern.trim().replace(/\\/g, "/");
123
127
  }
128
+ const ROOT_CONFIG_NAME_MATCHERS = [...DEFAULT_PROJECT_MANIFESTS, CODEGRAPH_CONFIG_FILE].map((pattern) => picomatch(normalizeGlobPattern(pattern), { dot: true }));
129
+ function isRootConfigFileName(fileName) {
130
+ return ROOT_CONFIG_NAME_MATCHERS.some((matcher) => matcher(fileName));
131
+ }
132
+ async function listRootConfigFiles(root, state) {
133
+ const entries = await readDirectoryWithFileTypes(root, state);
134
+ const files = [];
135
+ for (const entry of entries) {
136
+ if (!isRootConfigFileName(entry.name))
137
+ continue;
138
+ const file = normalizePath(path.join(root, entry.name));
139
+ if (!entry.isFile()) {
140
+ if (!entry.isSymbolicLink())
141
+ continue;
142
+ try {
143
+ if (!(await fsp.stat(file)).isFile())
144
+ continue;
145
+ }
146
+ catch (error) {
147
+ const code = error.code;
148
+ if (code === "ENOENT" || code === "ENOTDIR")
149
+ continue;
150
+ throw error;
151
+ }
152
+ }
153
+ files.push(file);
154
+ }
155
+ return files;
156
+ }
124
157
  const DIRECTORY_METADATA_EXTENSIONS = new Set();
125
158
  const DIRECTORY_METADATA_BASENAMES = new Set();
126
159
  for (const definition of PROJECT_FILE_DEFINITIONS) {
@@ -275,6 +308,126 @@ const EMPTY_GITIGNORE_INDEX = {
275
308
  repositoryAliases: [],
276
309
  byBaseDir: new Map(),
277
310
  };
311
+ class DiscoveryContext {
312
+ projectRoot;
313
+ git;
314
+ gitCandidatesByRoot = new Map();
315
+ ignoreIndexByKey = new Map();
316
+ fileTextByPath = new Map();
317
+ readdirCache = new Map();
318
+ globFilesystem;
319
+ constructor(projectRoot) {
320
+ this.projectRoot = projectRoot;
321
+ this.git = createGitDiscoveryCache();
322
+ this.globFilesystem = { readdir: createSharedReaddir(this) };
323
+ }
324
+ }
325
+ function createSharedReaddir(state) {
326
+ function readdir(directory, optionsOrCallback, callback) {
327
+ const listing = readDirectoryWithFileTypes(directory, state);
328
+ if (typeof optionsOrCallback === "function") {
329
+ listing.then((entries) => optionsOrCallback(null, entries.map((entry) => entry.name)), (error) => optionsOrCallback(error, []));
330
+ }
331
+ else if (callback) {
332
+ listing.then((entries) => callback(null, entries), (error) => callback(error, []));
333
+ }
334
+ }
335
+ return readdir;
336
+ }
337
+ function globFilesystemOption(state) {
338
+ if (!state)
339
+ return {};
340
+ return { fs: state.globFilesystem };
341
+ }
342
+ function globFilesystemField(state) {
343
+ if (!state)
344
+ return {};
345
+ return { globFilesystem: state.globFilesystem };
346
+ }
347
+ async function readDirectoryWithFileTypes(directory, state) {
348
+ if (!state)
349
+ return fsp.readdir(directory, { withFileTypes: true });
350
+ const key = fileIdentityKey(directory);
351
+ const cached = state.readdirCache.get(key);
352
+ if (cached)
353
+ return cached;
354
+ const pending = fsp.readdir(directory, { withFileTypes: true });
355
+ state.readdirCache.set(key, pending);
356
+ return pending;
357
+ }
358
+ async function readDiscoveryFileResult(state, filePath) {
359
+ if (!state) {
360
+ try {
361
+ return { ok: true, text: await fsp.readFile(filePath, "utf8") };
362
+ }
363
+ catch (error) {
364
+ return { ok: false, error };
365
+ }
366
+ }
367
+ const key = fileIdentityKey(filePath);
368
+ const cached = state.fileTextByPath.get(key);
369
+ if (cached)
370
+ return await cached;
371
+ const pending = (async () => {
372
+ try {
373
+ return { ok: true, text: await fsp.readFile(filePath, "utf8") };
374
+ }
375
+ catch (error) {
376
+ return { ok: false, error };
377
+ }
378
+ })();
379
+ state.fileTextByPath.set(key, pending);
380
+ return await pending;
381
+ }
382
+ export async function readProjectDiscoveryFileText(discoveryContext, filePath) {
383
+ const result = await readDiscoveryFileResult(discoveryContext, filePath);
384
+ if (result.ok)
385
+ return result.text;
386
+ if (result.error instanceof Error)
387
+ throw result.error;
388
+ throw new Error(`Failed to read ${filePath}: ${stringifyUnknown(result.error)}`);
389
+ }
390
+ async function sharedGitignoreIndex(state, cacheKey, load) {
391
+ if (!state)
392
+ return await load();
393
+ const cached = state.ignoreIndexByKey.get(cacheKey);
394
+ if (cached)
395
+ return await cached;
396
+ const pending = load();
397
+ state.ignoreIndexByKey.set(cacheKey, pending);
398
+ return await pending;
399
+ }
400
+ function rememberGitCandidates(state, root, candidates) {
401
+ if (!state)
402
+ return;
403
+ const key = fileIdentityKey(root);
404
+ if (state.gitCandidatesByRoot.has(key))
405
+ return;
406
+ state.gitCandidatesByRoot.set(key, Promise.resolve(candidates));
407
+ }
408
+ async function getSharedGitCandidates(context, root, logLevel, callbacks) {
409
+ const state = context;
410
+ const gitCache = context?.git;
411
+ if (!state)
412
+ return await listGitCandidateFiles(root, logLevel, callbacks, gitCache);
413
+ const key = fileIdentityKey(root);
414
+ const cached = state.gitCandidatesByRoot.get(key);
415
+ if (cached)
416
+ return await cached;
417
+ const pending = listGitCandidateFiles(root, logLevel, callbacks, gitCache);
418
+ state.gitCandidatesByRoot.set(key, pending);
419
+ return await pending;
420
+ }
421
+ function gitExcludeListOptions(gitCache) {
422
+ if (!gitCache)
423
+ return undefined;
424
+ return { discoveryCache: gitCache };
425
+ }
426
+ function gitUntrackedListOptions(gitCache) {
427
+ if (!gitCache)
428
+ return { respectGitignore: true };
429
+ return { respectGitignore: true, discoveryCache: gitCache };
430
+ }
278
431
  /**
279
432
  * Parse `.gitignore` rules from an already-discovered set of `.gitignore` files.
280
433
  *
@@ -284,7 +437,7 @@ const EMPTY_GITIGNORE_INDEX = {
284
437
  * child negation could re-include files. Depth-first ordering also keeps the "last match
285
438
  * wins" order deeper files depend on, and drops the locale sensitivity of `localeCompare`.
286
439
  */
287
- async function buildGitignoreIndex(sources, repositoryRoots = [], repositoryAliases = []) {
440
+ async function buildGitignoreIndex(sources, repositoryRoots = [], repositoryAliases = [], state) {
288
441
  const sorted = [...sources]
289
442
  .map(({ file, baseDir, repositoryRoot, priority }) => ({
290
443
  file: normalizePath(file),
@@ -316,13 +469,10 @@ async function buildGitignoreIndex(sources, repositoryRoots = [], repositoryAlia
316
469
  if (isIgnoredByGitignore(path.dirname(file), gitignoreIndex, true)) {
317
470
  continue;
318
471
  }
319
- let raw;
320
- try {
321
- raw = await fsp.readFile(file, "utf8");
322
- }
323
- catch {
472
+ const fileText = await readDiscoveryFileResult(state, file);
473
+ if (!fileText.ok)
324
474
  continue;
325
- }
475
+ const raw = fileText.text;
326
476
  const baseKey = fileIdentityKey(baseDir);
327
477
  for (const line of raw.split(/\r?\n/)) {
328
478
  const rule = parseGitignoreRule(baseDir, line);
@@ -338,16 +488,17 @@ async function buildGitignoreIndex(sources, repositoryRoots = [], repositoryAlia
338
488
  }
339
489
  return gitignoreIndex;
340
490
  }
341
- async function findGitignoreFiles(projectRoot) {
491
+ async function findGitignoreFiles(projectRoot, state) {
342
492
  return await fg(["**/.gitignore"], {
343
493
  cwd: projectRoot,
344
494
  absolute: true,
345
495
  dot: true,
346
496
  followSymbolicLinks: false,
347
497
  ignore: DEFAULT_PROJECT_FILE_IGNORES,
498
+ ...globFilesystemOption(state),
348
499
  });
349
500
  }
350
- async function loadGitignoreIndexForRootAliases(projectRoot) {
501
+ async function loadGitignoreIndexForRootAliases(projectRoot, state) {
351
502
  const roots = [projectRoot];
352
503
  const realRoot = await fsp.realpath(projectRoot);
353
504
  if (normalizePath(realRoot) !== normalizePath(projectRoot)) {
@@ -355,11 +506,11 @@ async function loadGitignoreIndexForRootAliases(projectRoot) {
355
506
  }
356
507
  const sources = [];
357
508
  for (const root of roots) {
358
- for (const file of await findGitignoreFiles(root)) {
509
+ for (const file of await findGitignoreFiles(root, state)) {
359
510
  sources.push({ file, baseDir: path.dirname(file) });
360
511
  }
361
512
  }
362
- return await buildGitignoreIndex(sources);
513
+ return await buildGitignoreIndex(sources, [], [], state);
363
514
  }
364
515
  /**
365
516
  * Whether `.gitignore` rules ignore `absolutePath`.
@@ -459,11 +610,12 @@ function collectCandidateAncestorDirectories(root, files) {
459
610
  }
460
611
  return directories;
461
612
  }
462
- function owningGitIgnoreSourceRoot(file, roots) {
613
+ export function owningGitIgnoreSourceRoot(file, roots, realpathMemo) {
463
614
  const normalized = normalizePath(file);
464
615
  let owning;
465
616
  for (const root of roots) {
466
- if (!isFilePathWithinRoot(root.path, normalized) && !isFilePathWithinRoot(root.repositoryRoot, normalized)) {
617
+ if (!isFilePathWithinRoot(root.path, normalized, realpathMemo) &&
618
+ !isFilePathWithinRoot(root.repositoryRoot, normalized, realpathMemo)) {
467
619
  continue;
468
620
  }
469
621
  if (!owning || root.repositoryRoot.length > owning.repositoryRoot.length) {
@@ -472,6 +624,24 @@ function owningGitIgnoreSourceRoot(file, roots) {
472
624
  }
473
625
  return owning;
474
626
  }
627
+ /**
628
+ * Ownership is pure path containment, so every candidate in the same parent directory
629
+ * shares one answer. The lookup and its realpath memo live only for this discovery pass,
630
+ * so a long-lived process cannot serve a stale owner after the tree changes.
631
+ */
632
+ export function createGitIgnoreSourceOwnershipLookup(roots, realpathMemo) {
633
+ const ownershipByDirectory = new Map();
634
+ return (file) => {
635
+ const directory = normalizePath(path.dirname(file));
636
+ const key = fileIdentityKey(directory);
637
+ if (ownershipByDirectory.has(key)) {
638
+ return ownershipByDirectory.get(key);
639
+ }
640
+ const owning = owningGitIgnoreSourceRoot(directory, roots, realpathMemo);
641
+ ownershipByDirectory.set(key, owning);
642
+ return owning;
643
+ };
644
+ }
475
645
  async function gitignoreFileIfPresent(directory) {
476
646
  const file = normalizePath(path.join(directory, ".gitignore"));
477
647
  try {
@@ -494,7 +664,7 @@ async function gitignoreFileIfPresent(directory) {
494
664
  * `.gitignore` files walks every gitignored tree looking for them, which times
495
665
  * out on a large ignored data directory even after file listing already finished.
496
666
  */
497
- async function findGitIgnoreSources(sourceRoots, candidateFiles) {
667
+ async function findGitIgnoreSources(sourceRoots, candidateFiles, gitCache) {
498
668
  const roots = Array.from(new Map(sourceRoots.map(({ path: sourceRoot, repositoryRoot }) => [
499
669
  fileIdentityKey(sourceRoot),
500
670
  { path: normalizePath(sourceRoot), repositoryRoot: normalizePath(repositoryRoot) },
@@ -510,13 +680,15 @@ async function findGitIgnoreSources(sourceRoots, candidateFiles) {
510
680
  for (const { path: sourceRoot, repositoryRoot } of roots) {
511
681
  addDirectory(sourceRoot, repositoryRoot);
512
682
  }
683
+ const realpathMemo = new Map();
684
+ const owningOf = createGitIgnoreSourceOwnershipLookup(roots, realpathMemo);
513
685
  for (const file of candidateFiles) {
514
- const owning = owningGitIgnoreSourceRoot(file, roots);
686
+ const owning = owningOf(file);
515
687
  if (!owning)
516
688
  continue;
517
689
  let dir = normalizePath(path.dirname(file));
518
690
  const stopKey = fileIdentityKey(owning.repositoryRoot);
519
- for (;;) {
691
+ while (true) {
520
692
  const dirKey = fileIdentityKey(dir);
521
693
  const walkKey = `${dirKey}\0${stopKey}`;
522
694
  if (walkedDirectoryKeys.has(walkKey))
@@ -527,7 +699,8 @@ async function findGitIgnoreSources(sourceRoots, candidateFiles) {
527
699
  break;
528
700
  const parent = normalizePath(path.dirname(dir));
529
701
  if (parent === dir ||
530
- (!isFilePathWithinRoot(owning.path, parent) && !isFilePathWithinRoot(owning.repositoryRoot, parent))) {
702
+ (!isFilePathWithinRoot(owning.path, parent, realpathMemo) &&
703
+ !isFilePathWithinRoot(owning.repositoryRoot, parent, realpathMemo))) {
531
704
  break;
532
705
  }
533
706
  dir = parent;
@@ -539,7 +712,7 @@ async function findGitIgnoreSources(sourceRoots, candidateFiles) {
539
712
  const file = await gitignoreFileIfPresent(dir);
540
713
  return file ? [{ file, baseDir: dir, repositoryRoot, priority: 2 }] : [];
541
714
  }),
542
- Promise.all(uniqueRepositoryRoots.map((repositoryRoot) => listGitExcludeFiles(repositoryRoot))),
715
+ Promise.all(uniqueRepositoryRoots.map((repositoryRoot) => listGitExcludeFiles(repositoryRoot, gitExcludeListOptions(gitCache)))),
543
716
  ]);
544
717
  return [
545
718
  ...gitignoreHits.flat(),
@@ -555,15 +728,15 @@ async function findGitIgnoreSources(sourceRoots, candidateFiles) {
555
728
  * Tracked plus untracked-but-not-ignored files are Git candidates. Submodule files retain
556
729
  * their own repository boundary, and source roots cover logical and physical aliases.
557
730
  */
558
- async function listGitCandidateFiles(root, logLevel, opts) {
731
+ async function listGitCandidateFiles(root, logLevel, opts, gitCache) {
559
732
  let gitListStart;
560
733
  let gitIgnoreStart;
561
734
  try {
562
735
  const realRoot = normalizePath(await fsp.realpath(root));
563
- const gitRoot = (await isGitRepo(root)) ? root : realRoot;
564
- if (!(await isGitRepo(gitRoot)))
736
+ const gitRoot = (await isGitRepo(root, gitCache)) ? root : realRoot;
737
+ if (!(await isGitRepo(gitRoot, gitCache)))
565
738
  return null;
566
- if (await isGitProjectRootIgnored(realRoot))
739
+ if (await isGitProjectRootIgnored(realRoot, gitCache))
567
740
  return null;
568
741
  const gitRepositoryRoot = await getGitRepositoryRoot(gitRoot);
569
742
  if (!gitRepositoryRoot)
@@ -571,9 +744,10 @@ async function listGitCandidateFiles(root, logLevel, opts) {
571
744
  emitDiscoveryActivity(opts?.onDiscoveryProgress, "Listing Git files");
572
745
  const listStartedAt = performance.now();
573
746
  gitListStart = listStartedAt;
747
+ const untrackedOptions = gitUntrackedListOptions(gitCache);
574
748
  const [tracked, untracked, stageSpecial] = await Promise.all([
575
749
  listTrackedFiles(gitRoot, { recurseSubmodules: true, ...(logLevel === undefined ? {} : { logLevel }) }),
576
- listUntrackedFiles(gitRoot, { respectGitignore: true }),
750
+ listUntrackedFiles(gitRoot, untrackedOptions),
577
751
  listGitStageSpecialPaths(gitRoot, { recurse: true }),
578
752
  ]);
579
753
  const submoduleDirectories = stageSpecial.gitlinks;
@@ -581,7 +755,7 @@ async function listGitCandidateFiles(root, logLevel, opts) {
581
755
  logicalPath: normalizePath(logicalPath),
582
756
  physicalPath: normalizePath(await fsp.realpath(logicalPath).catch(() => logicalPath)),
583
757
  })));
584
- const submoduleUntracked = await Promise.all(submoduleRoots.map(async ({ physicalPath }) => await listUntrackedFiles(physicalPath, { respectGitignore: true })));
758
+ const submoduleUntracked = await Promise.all(submoduleRoots.map(async ({ physicalPath }) => await listUntrackedFiles(physicalPath, untrackedOptions)));
585
759
  const rawFiles = Array.from(new Set([...tracked, ...untracked, ...submoduleUntracked.flat()])).sort();
586
760
  const needsLogicalRemap = normalizePath(gitRoot) !== normalizePath(root);
587
761
  const files = Array.from(new Set(rawFiles.map((file) => needsLogicalRemap && isFilePathWithinRoot(realRoot, file)
@@ -607,7 +781,7 @@ async function listGitCandidateFiles(root, logLevel, opts) {
607
781
  emitDiscoveryActivity(opts?.onDiscoveryProgress, "Listing Git ignore files");
608
782
  const ignoreStartedAt = performance.now();
609
783
  gitIgnoreStart = ignoreStartedAt;
610
- const gitignoreFiles = await findGitIgnoreSources(sourceRoots, files);
784
+ const gitignoreFiles = await findGitIgnoreSources(sourceRoots, files, gitCache);
611
785
  emitDiscoveryTiming(opts?.onDiscoveryTiming, "git-ignore", ignoreStartedAt);
612
786
  gitIgnoreStart = undefined;
613
787
  emitDiscoveryActivity(opts?.onDiscoveryProgress, "Listing Git ignore files", gitignoreFiles.length, gitignoreFiles.length);
@@ -673,15 +847,26 @@ async function listProjectFilesInternal(projectRoot, patterns = DEFAULT_PROJECT_
673
847
  // disk): on an Unreal project holding 100,168 files (818 tracked) the scan produced
674
848
  // 55,983 pattern-matching candidates, nearly all generated sources under a gitignored
675
849
  // `Intermediate/` tree, and discovery took 26.1s. Git lists 4,010 in about 111ms.
676
- // An aliased root or a gitignore-root override means Git's notion of the project is
677
- // not this call's, so those fall back to the scan.
678
- const attemptedGitCandidates = useGitignore && options?.gitignoreRoot === undefined && normalizePath(realRoot) === normalizePath(root);
850
+ // An aliased root or a gitignore-root override that points elsewhere means Git's notion
851
+ // of the project is not this call's, so those fall back to the scan. Passing
852
+ // `gitignoreRoot` equal to the scan root (the CLI's historical whole-project form) still
853
+ // wants this repository's ignore rules, so keep the Git candidate path. On a 501-file
854
+ // Git repo with a 20,000-file gitignored tree, `gitignoreRoot: scanRoot` otherwise
855
+ // forced a filesystem scan plus a full-tree symlink probe (~403ms) instead of Git
856
+ // listing (~66ms) of the 502 tracked paths.
857
+ // Compare by identity key, not slash normalization: on a case-insensitive filesystem a
858
+ // casing or drive-letter difference still names the same directory, and calling that a
859
+ // mismatch would silently drop back to the slow scan this fast path exists to avoid.
860
+ const scanRootKey = fileIdentityKey(root);
861
+ const gitignoreRootMatchesScanRoot = options?.gitignoreRoot === undefined || fileIdentityKey(options.gitignoreRoot) === scanRootKey;
862
+ const attemptedGitCandidates = useGitignore && gitignoreRootMatchesScanRoot && fileIdentityKey(realRoot) === scanRootKey;
679
863
  const gitDiscoveryCallbacks = {
680
864
  ...(options?.onDiscoveryProgress ? { onDiscoveryProgress: options.onDiscoveryProgress } : {}),
681
865
  ...(options?.onDiscoveryTiming ? { onDiscoveryTiming: options.onDiscoveryTiming } : {}),
682
866
  };
867
+ const discovery = options?.discoveryContext ?? createProjectDiscoveryContext(root);
683
868
  const gitCandidates = attemptedGitCandidates
684
- ? await listGitCandidateFiles(root, options?.logLevel, gitDiscoveryCallbacks)
869
+ ? await getSharedGitCandidates(discovery, root, options?.logLevel, gitDiscoveryCallbacks)
685
870
  : null;
686
871
  // Report only when this call actually asked Git, including a null result. Callers that
687
872
  // already enumerated candidates (build-index) can hand the same set to metadata discovery
@@ -691,13 +876,13 @@ async function listProjectFilesInternal(projectRoot, patterns = DEFAULT_PROJECT_
691
876
  }
692
877
  let gitignoreIndex = EMPTY_GITIGNORE_INDEX;
693
878
  if (useGitignore && gitCandidates) {
694
- gitignoreIndex = await buildGitignoreIndex(gitCandidates.gitignoreFiles, gitCandidates.gitignoreRoots, gitCandidates.gitignoreAliases);
879
+ gitignoreIndex = await sharedGitignoreIndex(discovery, `git:${fileIdentityKey(root)}`, () => buildGitignoreIndex(gitCandidates.gitignoreFiles, gitCandidates.gitignoreRoots, gitCandidates.gitignoreAliases, discovery));
695
880
  }
696
881
  else if (useGitignore) {
697
882
  const gitignoreRoot = options?.gitignoreRoot
698
883
  ? await ensureDirectoryReadable(options.gitignoreRoot, "Gitignore root")
699
884
  : root;
700
- gitignoreIndex = await loadGitignoreIndexForRootAliases(gitignoreRoot);
885
+ gitignoreIndex = await sharedGitignoreIndex(discovery, `fs:${fileIdentityKey(gitignoreRoot)}`, () => loadGitignoreIndexForRootAliases(gitignoreRoot, discovery));
701
886
  }
702
887
  // Git returns a candidate set, not a filtered result: the pattern, include,
703
888
  // default-ignore, and gitignore checks below still decide what is indexable.
@@ -714,6 +899,7 @@ async function listProjectFilesInternal(projectRoot, patterns = DEFAULT_PROJECT_
714
899
  dot: true,
715
900
  followSymbolicLinks: false,
716
901
  ignore: fastGlobIgnoreGlobs,
902
+ ...globFilesystemOption(discovery),
717
903
  });
718
904
  emitDiscoveryTiming(options?.onDiscoveryTiming, "filesystem-scan", scanStart);
719
905
  emitDiscoveryActivity(options?.onDiscoveryProgress, "Scanning project files", files.length, files.length);
@@ -733,6 +919,7 @@ async function listProjectFilesInternal(projectRoot, patterns = DEFAULT_PROJECT_
733
919
  dot: true,
734
920
  followSymbolicLinks: false,
735
921
  ignore: translatedUserIgnoreGlobs,
922
+ ...globFilesystemOption(discovery),
736
923
  })
737
924
  : [];
738
925
  const reportSourceSymlinkChecks = options?.onDiscoveryProgress
@@ -751,6 +938,7 @@ async function listProjectFilesInternal(projectRoot, patterns = DEFAULT_PROJECT_
751
938
  ? { onSymlinkDirectoriesDiscovered: options.onSymlinkDirectoriesDiscovered }
752
939
  : {}),
753
940
  ...(reportSourceSymlinkChecks ? { onPathCheckProgress: reportSourceSymlinkChecks } : {}),
941
+ ...globFilesystemField(discovery),
754
942
  };
755
943
  const safeSymlinkDirectories = await resolveSafeSymlinkDirectories(root, realRoot, symlinkProbeIgnoreGlobs, symlinkOptions);
756
944
  const linkedFiles = await listEntriesFromSafeSymlinkDirectories(root, realRoot, patterns, fastGlobIgnoreGlobs, {
@@ -764,6 +952,7 @@ async function listProjectFilesInternal(projectRoot, patterns = DEFAULT_PROJECT_
764
952
  globRoot,
765
953
  filterIgnoreGlobs: userIgnoreGlobs,
766
954
  resolvedSafeSymlinkDirectories: safeSymlinkDirectories,
955
+ ...globFilesystemField(discovery),
767
956
  });
768
957
  // Cheap path predicates run before the realpath confinement probe. Git enumerates
769
958
  // every tracked and untracked file it knows about, including trees this project
@@ -928,6 +1117,7 @@ async function resolveSafeSymlinkDirectories(root, realRoot, ignore, options) {
928
1117
  followSymbolicLinks: false,
929
1118
  objectMode: true,
930
1119
  ignore,
1120
+ ...(options.globFilesystem ? { fs: options.globFilesystem } : {}),
931
1121
  }));
932
1122
  const discovered = await verifySafeSymlinkDirectories(root, realRoot, entries.filter((entry) => entry.dirent.isSymbolicLink()).map((entry) => entry.path), options.onPathCheckProgress);
933
1123
  options.onSymlinkDirectoriesDiscovered?.(discovered, "filesystem");
@@ -953,6 +1143,7 @@ async function listEntriesFromSafeSymlinkDirectories(root, realRoot, patterns, i
953
1143
  ignore: locationIndependentIgnores,
954
1144
  ...(options.onlyFiles !== undefined ? { onlyFiles: options.onlyFiles } : {}),
955
1145
  ...(options.markDirectories !== undefined ? { markDirectories: options.markDirectories } : {}),
1146
+ ...(options.globFilesystem ? { fs: options.globFilesystem } : {}),
956
1147
  })).filter((filePath) => {
957
1148
  const cleanPath = filePath.endsWith("/") ? filePath.slice(0, -1) : filePath;
958
1149
  return !rootRelativeIgnoreMatchers.some((matcher) => matchesDiscoveryGlob(cleanPath, globRoot, matcher));
@@ -987,14 +1178,14 @@ export async function filterRealPathsWithinRoot(paths, realRoot, onPathCheckProg
987
1178
  function toProjectGlob(pattern) {
988
1179
  return pattern.startsWith("**/") ? pattern : `**/${pattern}`;
989
1180
  }
990
- async function buildProjectFileInfo(def, filePath) {
1181
+ async function buildProjectFileInfo(def, filePath, state) {
991
1182
  const normalizedPath = normalizePath(filePath);
992
1183
  const projectRoot = normalizePath(path.dirname(filePath));
993
1184
  let name = null;
994
1185
  if (def.parseName && def.kind === "file") {
995
1186
  try {
996
- const raw = await fsp.readFile(filePath, "utf8");
997
- name = def.parseName(raw, filePath);
1187
+ const text = await readProjectDiscoveryFileText(state, filePath);
1188
+ name = def.parseName(text, filePath);
998
1189
  }
999
1190
  catch {
1000
1191
  name = null;
@@ -1037,12 +1228,14 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1037
1228
  return matcher ? matcher.test(fileName) : pattern === fileName;
1038
1229
  });
1039
1230
  };
1231
+ const discovery = options?.discoveryContext ?? createProjectDiscoveryContext(root);
1040
1232
  let gitCandidates;
1041
1233
  if (options?.knownGitCandidates !== undefined) {
1042
1234
  gitCandidates = options.knownGitCandidates;
1235
+ rememberGitCandidates(discovery, root, gitCandidates);
1043
1236
  }
1044
1237
  else {
1045
- gitCandidates = await listGitCandidateFiles(root, options?.logLevel, {
1238
+ gitCandidates = await getSharedGitCandidates(discovery, root, options?.logLevel, {
1046
1239
  ...(options?.onDiscoveryProgress ? { onDiscoveryProgress: options.onDiscoveryProgress } : {}),
1047
1240
  ...(options?.onDiscoveryTiming ? { onDiscoveryTiming: options.onDiscoveryTiming } : {}),
1048
1241
  });
@@ -1059,7 +1252,7 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1059
1252
  : undefined;
1060
1253
  let rootSafeMatches;
1061
1254
  if (gitCandidates) {
1062
- const gitignoreIndex = await buildGitignoreIndex(gitCandidates.gitignoreFiles, gitCandidates.gitignoreRoots, gitCandidates.gitignoreAliases);
1255
+ const gitignoreIndex = await sharedGitignoreIndex(discovery, `git:${fileIdentityKey(root)}`, () => buildGitignoreIndex(gitCandidates.gitignoreFiles, gitCandidates.gitignoreRoots, gitCandidates.gitignoreAliases, discovery));
1063
1256
  const defaultIgnoreMatchers = DEFAULT_PROJECT_FILE_IGNORES.map((globPattern) => picomatch(globPattern, { dot: true }));
1064
1257
  const filterGitIgnoredEntries = (entries, directoryKeys = new Set()) => entries
1065
1258
  .map(({ path: filePath, realPath }) => ({ filePath: normalizePath(filePath), realPath }))
@@ -1084,6 +1277,7 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1084
1277
  ? { onSymlinkDirectoriesDiscovered: options.onSymlinkDirectoriesDiscovered }
1085
1278
  : {}),
1086
1279
  ...(reportMetadataSymlinkChecks ? { onPathCheckProgress: reportMetadataSymlinkChecks } : {}),
1280
+ ...globFilesystemField(discovery),
1087
1281
  };
1088
1282
  const safeSymlinkDirectories = await resolveSafeSymlinkDirectories(root, realRoot, DEFAULT_PROJECT_FILE_IGNORES, symlinkOptions);
1089
1283
  const allPatterns = PROJECT_FILE_DEFINITIONS.flatMap((definition) => definition.patterns.map(toProjectGlob));
@@ -1091,6 +1285,7 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1091
1285
  markDirectories: true,
1092
1286
  onlyFiles: false,
1093
1287
  resolvedSafeSymlinkDirectories: safeSymlinkDirectories,
1288
+ ...globFilesystemField(discovery),
1094
1289
  });
1095
1290
  const candidateFiles = Array.from(new Set(gitCandidates.files.map(normalizePath)));
1096
1291
  // Only a candidate whose basename can name a manifest becomes a metadata entry, so
@@ -1123,6 +1318,7 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1123
1318
  ignore: DEFAULT_PROJECT_FILE_IGNORES,
1124
1319
  markDirectories: true,
1125
1320
  onlyFiles: false,
1321
+ ...globFilesystemOption(discovery),
1126
1322
  });
1127
1323
  const linkedMatches = await listEntriesFromSafeSymlinkDirectories(root, realRoot, allPatterns, DEFAULT_PROJECT_FILE_IGNORES, {
1128
1324
  markDirectories: true,
@@ -1134,6 +1330,7 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1134
1330
  ? { onSymlinkDirectoriesDiscovered: options.onSymlinkDirectoriesDiscovered }
1135
1331
  : {}),
1136
1332
  ...(reportMetadataSymlinkChecks ? { onPathCheckProgress: reportMetadataSymlinkChecks } : {}),
1333
+ ...globFilesystemField(discovery),
1137
1334
  });
1138
1335
  rootSafeMatches = await filterRealPathsWithinRoot([...matches, ...linkedMatches].map((match) => (match.endsWith("/") ? match.slice(0, -1) : match)), realRoot, reportMetadataFileChecks);
1139
1336
  }
@@ -1150,7 +1347,7 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1150
1347
  continue;
1151
1348
  const matchesPattern = matchesDefinition(fileName, definitionIndex);
1152
1349
  if (matchesPattern) {
1153
- entries.push(await buildProjectFileInfo(def, cleanMatch));
1350
+ entries.push(await buildProjectFileInfo(def, cleanMatch, discovery));
1154
1351
  }
1155
1352
  }
1156
1353
  });
@@ -1174,4 +1371,88 @@ async function discoverProjectFilesInternal(projectRoot, options) {
1174
1371
  throw new Error(`Failed to discover project files in ${root}: ${stringifyUnknown(error)}`);
1175
1372
  }
1176
1373
  }
1374
+ async function addGitExcludeFiles(files, repositoryRoot, gitCache) {
1375
+ const excludes = await listGitExcludeFiles(repositoryRoot, gitExcludeListOptions(gitCache));
1376
+ for (const source of excludes) {
1377
+ files.add(normalizePath(source.file));
1378
+ }
1379
+ }
1380
+ async function collectAncestorGitignoreFiles(fromDir, stopDir) {
1381
+ const files = [];
1382
+ let current = normalizePath(fromDir);
1383
+ const stopKey = fileIdentityKey(stopDir);
1384
+ while (true) {
1385
+ const hit = await gitignoreFileIfPresent(current);
1386
+ if (hit)
1387
+ files.push(hit);
1388
+ if (fileIdentityKey(current) === stopKey)
1389
+ break;
1390
+ const parent = normalizePath(path.dirname(current));
1391
+ if (parent === current)
1392
+ break;
1393
+ if (!isFilePathWithinRoot(stopDir, parent) && fileIdentityKey(parent) !== stopKey)
1394
+ break;
1395
+ current = parent;
1396
+ }
1397
+ return files;
1398
+ }
1399
+ async function addGitignoreFilesForRoots(files, roots, state, scanned) {
1400
+ for (const ignoreRoot of roots) {
1401
+ const key = fileIdentityKey(ignoreRoot);
1402
+ if (scanned.has(key))
1403
+ continue;
1404
+ scanned.add(key);
1405
+ for (const file of await findGitignoreFiles(ignoreRoot, state)) {
1406
+ files.add(normalizePath(file));
1407
+ }
1408
+ }
1409
+ }
1410
+ export async function collectConfigHashInputFiles(projectRoot, discoveryContext, logLevel, callbacks) {
1411
+ const root = await ensureDirectoryReadable(projectRoot, "Project root");
1412
+ const state = discoveryContext;
1413
+ const files = new Set(await listRootConfigFiles(root, state));
1414
+ const gitCache = discoveryContext.git;
1415
+ const gitCandidates = await getSharedGitCandidates(discoveryContext, root, logLevel, callbacks);
1416
+ if (gitCandidates) {
1417
+ for (const source of gitCandidates.gitignoreFiles) {
1418
+ files.add(normalizePath(source.file));
1419
+ }
1420
+ return { files: Array.from(files).sort() };
1421
+ }
1422
+ const realRoot = normalizePath(await fsp.realpath(root));
1423
+ const gitRootCandidate = (await isGitRepo(root, gitCache)) ? root : realRoot;
1424
+ if (!(await isGitRepo(gitRootCandidate, gitCache))) {
1425
+ await addGitignoreFilesForRoots(files, [root], state, new Set());
1426
+ return { files: Array.from(files).sort() };
1427
+ }
1428
+ if (await isGitProjectRootIgnored(realRoot, gitCache)) {
1429
+ const scanned = new Set();
1430
+ await addGitignoreFilesForRoots(files, [root], state, scanned);
1431
+ const gitRoot = await getGitRepositoryRoot(gitRootCandidate);
1432
+ if (gitRoot) {
1433
+ await addGitExcludeFiles(files, gitRoot, gitCache);
1434
+ const submoduleDirectories = await listGitSubmoduleDirectories(gitRoot, { recurse: true });
1435
+ await addGitignoreFilesForRoots(files, [gitRoot, ...submoduleDirectories], state, scanned);
1436
+ for (const submodule of submoduleDirectories) {
1437
+ await addGitExcludeFiles(files, submodule, gitCache);
1438
+ }
1439
+ }
1440
+ return { files: Array.from(files).sort() };
1441
+ }
1442
+ const gitRoot = await getGitRepositoryRoot(gitRootCandidate);
1443
+ if (gitRoot) {
1444
+ for (const file of await collectAncestorGitignoreFiles(root, gitRoot)) {
1445
+ files.add(normalizePath(file));
1446
+ }
1447
+ await addGitExcludeFiles(files, gitRoot, gitCache);
1448
+ const submoduleDirectories = await listGitSubmoduleDirectories(gitRoot, { recurse: true });
1449
+ for (const submodule of submoduleDirectories) {
1450
+ await addGitExcludeFiles(files, submodule, gitCache);
1451
+ }
1452
+ }
1453
+ return {
1454
+ files: Array.from(files).sort(),
1455
+ error: "Failed to enumerate config files: Git listing unavailable",
1456
+ };
1457
+ }
1177
1458
  //# sourceMappingURL=projectFiles.js.map