@vibe-agent-toolkit/utils 0.2.0-rc.2 → 0.2.0-rc.3

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 (102) hide show
  1. package/README.md +6 -3
  2. package/dist/compare-code-units.d.ts +28 -0
  3. package/dist/compare-code-units.d.ts.map +1 -0
  4. package/dist/compare-code-units.js +33 -0
  5. package/dist/compare-code-units.js.map +1 -0
  6. package/dist/crawl-timing.d.ts +680 -0
  7. package/dist/crawl-timing.d.ts.map +1 -0
  8. package/dist/crawl-timing.js +809 -0
  9. package/dist/crawl-timing.js.map +1 -0
  10. package/dist/file-crawler.d.ts +22 -0
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +31 -7
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +17 -0
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +69 -0
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +3 -1
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +5 -1
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-run.d.ts +167 -0
  23. package/dist/git-run.d.ts.map +1 -0
  24. package/dist/git-run.js +154 -0
  25. package/dist/git-run.js.map +1 -0
  26. package/dist/git-snapshot.d.ts +181 -0
  27. package/dist/git-snapshot.d.ts.map +1 -0
  28. package/dist/git-snapshot.js +232 -0
  29. package/dist/git-snapshot.js.map +1 -0
  30. package/dist/git-tracker.d.ts +94 -1
  31. package/dist/git-tracker.d.ts.map +1 -1
  32. package/dist/git-tracker.js +135 -3
  33. package/dist/git-tracker.js.map +1 -1
  34. package/dist/git-utils.d.ts +38 -0
  35. package/dist/git-utils.d.ts.map +1 -1
  36. package/dist/git-utils.js +103 -80
  37. package/dist/git-utils.js.map +1 -1
  38. package/dist/git.d.ts +2 -1
  39. package/dist/git.d.ts.map +1 -1
  40. package/dist/git.js +2 -1
  41. package/dist/git.js.map +1 -1
  42. package/dist/gitignore-checker.d.ts.map +1 -1
  43. package/dist/gitignore-checker.js +6 -3
  44. package/dist/gitignore-checker.js.map +1 -1
  45. package/dist/index.d.ts +8 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +42 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/link-auth/expand-macro.d.ts.map +1 -1
  50. package/dist/link-auth/expand-macro.js +7 -1
  51. package/dist/link-auth/expand-macro.js.map +1 -1
  52. package/dist/link-auth/resolve-token.d.ts.map +1 -1
  53. package/dist/link-auth/resolve-token.js +15 -1
  54. package/dist/link-auth/resolve-token.js.map +1 -1
  55. package/dist/path-core.d.ts +1 -1
  56. package/dist/path-core.js +1 -1
  57. package/dist/project-utils.d.ts.map +1 -1
  58. package/dist/project-utils.js +6 -3
  59. package/dist/project-utils.js.map +1 -1
  60. package/dist/safe-exec.d.ts +38 -10
  61. package/dist/safe-exec.d.ts.map +1 -1
  62. package/dist/safe-exec.js +88 -19
  63. package/dist/safe-exec.js.map +1 -1
  64. package/dist/skill-test/index.d.ts +1 -1
  65. package/dist/skill-test/index.d.ts.map +1 -1
  66. package/dist/skill-test/index.js +1 -1
  67. package/dist/skill-test/index.js.map +1 -1
  68. package/dist/skill-test/spawn-claude.d.ts +28 -0
  69. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  70. package/dist/skill-test/spawn-claude.js +51 -2
  71. package/dist/skill-test/spawn-claude.js.map +1 -1
  72. package/dist/skill-test/transcript.d.ts +41 -2
  73. package/dist/skill-test/transcript.d.ts.map +1 -1
  74. package/dist/skill-test/transcript.js +47 -3
  75. package/dist/skill-test/transcript.js.map +1 -1
  76. package/dist/test-helpers.d.ts +185 -13
  77. package/dist/test-helpers.d.ts.map +1 -1
  78. package/dist/test-helpers.js +274 -27
  79. package/dist/test-helpers.js.map +1 -1
  80. package/dist/text-content.d.ts +197 -0
  81. package/dist/text-content.d.ts.map +1 -0
  82. package/dist/text-content.js +348 -0
  83. package/dist/text-content.js.map +1 -0
  84. package/dist/text-file.d.ts +45 -0
  85. package/dist/text-file.d.ts.map +1 -0
  86. package/dist/text-file.js +53 -0
  87. package/dist/text-file.js.map +1 -0
  88. package/dist/text.d.ts +16 -0
  89. package/dist/text.d.ts.map +1 -0
  90. package/dist/text.js +16 -0
  91. package/dist/text.js.map +1 -0
  92. package/dist/timing-dump.d.ts +119 -0
  93. package/dist/timing-dump.d.ts.map +1 -0
  94. package/dist/timing-dump.js +164 -0
  95. package/dist/timing-dump.js.map +1 -0
  96. package/eslint/README.md +105 -3
  97. package/eslint/index.cjs +41 -0
  98. package/eslint/index.d.cts +7 -4
  99. package/eslint/rules/no-bare-symlink-in-tests.cjs +200 -0
  100. package/eslint/rules/no-raw-text-decode.cjs +296 -0
  101. package/eslint/rules/no-self-package-import.cjs +157 -0
  102. package/package.json +9 -2
@@ -56,6 +56,12 @@ export declare class GitTracker {
56
56
  private readonly activeSet;
57
57
  /** Absolute paths of every directory that contains at least one active-set file. */
58
58
  private readonly activeAncestors;
59
+ /**
60
+ * Lowercased absolute path → the root-relative path spelled the way git
61
+ * spelled it. Built from the very same `git ls-files` output `activeSet`
62
+ * comes from, so it costs one extra `Map` and no extra git invocation.
63
+ */
64
+ private readonly indexPaths;
59
65
  private initialized;
60
66
  private activeSetPopulated;
61
67
  /** Whether `git ls-files` actually answered during {@link initialize}. */
@@ -72,8 +78,60 @@ export declare class GitTracker {
72
78
  * With `includeUntracked: false`, only tracked files are pre-populated.
73
79
  * Untracked non-ignored files will miss the cache and fall through to
74
80
  * `git check-ignore` via {@link isIgnored}.
81
+ *
82
+ * ## This is the one bracket in the crawl-timing seam's `shared` stratum
83
+ *
84
+ * The `git ls-files` spawn below is preparation BOTH crawlers consume and
85
+ * NEITHER owns — the incumbent link walk and the projection's contributors are
86
+ * each handed a tracker by their caller — so it is charged to
87
+ * {@link CRAWL_SHARED_GIT_TRACKER_ID}, in a stratum belonging to no arm.
88
+ *
89
+ * The bracket is here, inside the class, and not at the six sites that build a
90
+ * tracker, for the reason `crawl-timing.ts` gives about `ResourceRegistry`: six
91
+ * copies are six chances to disagree, and a seventh site added later would
92
+ * silently go uncharged. Here, every caller is covered by construction — which
93
+ * includes `@vibe-agent-toolkit/discovery`, a package that could not have filed
94
+ * a row from its own call site at all, since it depends on `utils` alone.
95
+ *
96
+ * The early return above is deliberately OUTSIDE it: a re-entrant call does no
97
+ * work, and charging it would inflate `calls` with questions rather than
98
+ * spawns.
75
99
  */
76
100
  initialize(options?: GitTrackerInitOptions): Promise<void>;
101
+ /**
102
+ * The active set read off a snapshot this process has ALREADY taken — or
103
+ * `null` to say "ask git yourself".
104
+ *
105
+ * ## Why this is the same question, not a similar one
106
+ *
107
+ * `git ls-files --cached --others --exclude-standard` returns
108
+ * `tracked ∪ (untracked ∧ ¬ignored)`. A snapshot is `git add --all` **without**
109
+ * `--force` staged into a throwaway index, whose membership is that same set
110
+ * by construction — the exclusion of ignored paths is `--force`'s absence in
111
+ * both cases. So where a snapshot is already in hand, the spawn this method
112
+ * replaces would re-derive a set the process is already holding.
113
+ *
114
+ * It is deliberately a PEEK ({@link peekGitTreeSnapshot}) and never a take: a
115
+ * snapshot costs a `git add --all`, so causing one to avoid an `ls-files`
116
+ * would be a large loss dressed as a saving. On the incumbent walk no snapshot
117
+ * is ever taken, the peek misses, and this tracker spawns exactly as it always
118
+ * has.
119
+ *
120
+ * ## The one divergence, and why it is an improvement
121
+ *
122
+ * `--cached` reads the REAL index, so a tracked file deleted from the working
123
+ * tree is still listed. `git add --all` stages that deletion, so the snapshot
124
+ * omits it. The set is therefore not identical — it is the same set minus
125
+ * paths that do not exist, which is what {@link isIgnoredByActiveSet} already
126
+ * documents the active set to be ("it can only ever contain paths that
127
+ * EXIST"). The snapshot-sourced set honours that sentence more exactly than
128
+ * the spawn does.
129
+ *
130
+ * @param includeUntracked - The caller's requested membership
131
+ * @returns Root-relative paths in `git ls-files` shape, or `null` when no
132
+ * snapshot is available or the request is one a snapshot cannot answer
133
+ */
134
+ private activePathsFromOpenSnapshot;
77
135
  /**
78
136
  * Did git actually answer, or is this tracker an empty shell?
79
137
  *
@@ -139,8 +197,21 @@ export declare class GitTracker {
139
197
  * possible per-path spawn.
140
198
  *
141
199
  * @param absolutePath - Absolute path to check
200
+ * @param knownToExist - The caller's own answer to the existence question, when
201
+ * it has already asked. Supplying it skips this method's `existsSync`, which
202
+ * is otherwise paid once per path that is absent from the active set — i.e.
203
+ * once per ignored path, and the projection's `filesystem` extent enumerates
204
+ * all of them (11,108 calls on an 8,496-path adopter tree).
205
+ *
206
+ * **It must mean what `existsSync` means: `stat` succeeds, following
207
+ * symlinks.** A caller holding only an `lstat` result has a DIFFERENT fact —
208
+ * `lstat` succeeds on a dangling symlink where `existsSync` returns false —
209
+ * and must narrow it to `exists && symlinkResolves !== false` rather than
210
+ * pass the `lstat` boolean through, or dangling symlinks silently stop
211
+ * falling back to `git check-ignore` and start reporting as ignored.
212
+ * Omit it and nothing changes.
142
213
  */
143
- isIgnoredByActiveSet(absolutePath: string): boolean;
214
+ isIgnoredByActiveSet(absolutePath: string, knownToExist?: boolean): boolean;
144
215
  private isWithinProjectRoot;
145
216
  /**
146
217
  * Check if a file is ignored by git.
@@ -151,6 +222,28 @@ export declare class GitTracker {
151
222
  * @returns true if file is gitignored, false otherwise
152
223
  */
153
224
  isIgnored(filePath: string): boolean;
225
+ /**
226
+ * The spelling git records for a path, or `null` if git has no record of it.
227
+ *
228
+ * This is the casing oracle, not another ignore check. On a case-insensitive
229
+ * filesystem `docs/Readme.md` and `docs/README.md` are one inode with two
230
+ * spellings, and Node's two `realpath` implementations disagree about which
231
+ * one they hand back — so anything that derives an identity from a path needs
232
+ * a single authoritative spelling, and git's is it wherever git has one.
233
+ *
234
+ * The lookup key is lowercased, which is the point: the caller asks with
235
+ * whatever casing it observed and gets back the casing git holds.
236
+ *
237
+ * Answers only from the pre-populated set — never spawns. A path git does not
238
+ * know (untracked-and-ignored, non-existent, outside the project root, or any
239
+ * path at all when `git ls-files` did not answer) returns `null`, and the
240
+ * caller falls back to the on-disk casing.
241
+ *
242
+ * @param absolutePath - Absolute path to look up
243
+ * @returns Root-relative, forward-slashed path as git spells it — relative to
244
+ * THIS tracker's project root — or `null` when git has no record of it
245
+ */
246
+ indexPathFor(absolutePath: string): string | null;
154
247
  /**
155
248
  * Get cache statistics.
156
249
  */
@@ -1 +1 @@
1
- {"version":3,"file":"git-tracker.d.ts","sourceRoot":"","sources":["../src/git-tracker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAS;IAC/C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmC;IACzD,6FAA6F;IAC7F,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,oFAAoF;IACpF,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA0B;IAC1D,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,kBAAkB,CAAS;IACnC,0EAA0E;IAC1E,OAAO,CAAC,WAAW,CAAS;gBAEhB,WAAW,EAAE,MAAM;IAK/B;;;;;;;;;;;OAWG;IACG,UAAU,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IA0BhE;;;;;;;;;;;;;OAaG;IACH,QAAQ,IAAI,OAAO;IAInB;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAwB3B;;;;;;;;;;;OAWG;IACH,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IAQlD;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IA0BnD,OAAO,CAAC,mBAAmB;IAQ3B;;;;;;;OAOG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAqBpC;;OAEG;IACH,QAAQ,IAAI;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE;IAQrF;;OAEG;IACH,KAAK,IAAI,IAAI;CAQd"}
1
+ {"version":3,"file":"git-tracker.d.ts","sourceRoot":"","sources":["../src/git-tracker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAcH;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAS;IAC/C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmC;IACzD,6FAA6F;IAC7F,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,oFAAoF;IACpF,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA0B;IAC1D;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;IAC7D,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,kBAAkB,CAAS;IACnC,0EAA0E;IAC1E,OAAO,CAAC,WAAW,CAAS;gBAEhB,WAAW,EAAE,MAAM;IAK/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACG,UAAU,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiChE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,OAAO,CAAC,2BAA2B;IA6BnC;;;;;;;;;;;;;OAaG;IACH,QAAQ,IAAI,OAAO;IAInB;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAwB3B;;;;;;;;;;;OAWG;IACH,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IAQlD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAyCG;IACH,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,OAAO,GAAG,OAAO;IA2B3E,OAAO,CAAC,mBAAmB;IAQ3B;;;;;;;OAOG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAqBpC;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAIjD;;OAEG;IACH,QAAQ,IAAI;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE;IAQrF;;OAEG;IACH,KAAK,IAAI,IAAI;CASd"}
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { existsSync } from 'node:fs';
8
8
  import { dirname } from 'node:path';
9
+ import { CRAWL_SHARED_GIT_TRACKER_ID, crawlTimingStart, recordSharedPass, } from './crawl-timing.js';
10
+ import { peekGitTreeSnapshot } from './git-snapshot.js';
9
11
  import { gitLsFiles, isGitIgnored } from './git-utils.js';
10
12
  import { safePath, toForwardSlash } from './path-utils.js';
11
13
  /**
@@ -42,6 +44,12 @@ export class GitTracker {
42
44
  activeSet = new Set();
43
45
  /** Absolute paths of every directory that contains at least one active-set file. */
44
46
  activeAncestors = new Set();
47
+ /**
48
+ * Lowercased absolute path → the root-relative path spelled the way git
49
+ * spelled it. Built from the very same `git ls-files` output `activeSet`
50
+ * comes from, so it costs one extra `Map` and no extra git invocation.
51
+ */
52
+ indexPaths = new Map();
45
53
  initialized = false;
46
54
  activeSetPopulated = false;
47
55
  /** Whether `git ls-files` actually answered during {@link initialize}. */
@@ -61,13 +69,32 @@ export class GitTracker {
61
69
  * With `includeUntracked: false`, only tracked files are pre-populated.
62
70
  * Untracked non-ignored files will miss the cache and fall through to
63
71
  * `git check-ignore` via {@link isIgnored}.
72
+ *
73
+ * ## This is the one bracket in the crawl-timing seam's `shared` stratum
74
+ *
75
+ * The `git ls-files` spawn below is preparation BOTH crawlers consume and
76
+ * NEITHER owns — the incumbent link walk and the projection's contributors are
77
+ * each handed a tracker by their caller — so it is charged to
78
+ * {@link CRAWL_SHARED_GIT_TRACKER_ID}, in a stratum belonging to no arm.
79
+ *
80
+ * The bracket is here, inside the class, and not at the six sites that build a
81
+ * tracker, for the reason `crawl-timing.ts` gives about `ResourceRegistry`: six
82
+ * copies are six chances to disagree, and a seventh site added later would
83
+ * silently go uncharged. Here, every caller is covered by construction — which
84
+ * includes `@vibe-agent-toolkit/discovery`, a package that could not have filed
85
+ * a row from its own call site at all, since it depends on `utils` alone.
86
+ *
87
+ * The early return above is deliberately OUTSIDE it: a re-entrant call does no
88
+ * work, and charging it would inflate `calls` with questions rather than
89
+ * spawns.
64
90
  */
65
91
  async initialize(options) {
66
92
  if (this.initialized) {
67
93
  return;
68
94
  }
95
+ const startedAt = crawlTimingStart();
69
96
  const includeUntracked = options?.includeUntracked ?? true;
70
- const files = gitLsFiles({
97
+ const files = this.activePathsFromOpenSnapshot(includeUntracked) ?? gitLsFiles({
71
98
  cwd: this.projectRoot,
72
99
  ...(includeUntracked ? { includeUntracked: true } : {}),
73
100
  });
@@ -76,12 +103,78 @@ export class GitTracker {
76
103
  const absolutePath = safePath.resolve(this.projectRoot, relativePath);
77
104
  this.cache.set(absolutePath, false); // false = not ignored
78
105
  this.activeSet.add(absolutePath);
106
+ this.indexPaths.set(absolutePath.toLowerCase(), toForwardSlash(relativePath));
79
107
  }
80
108
  this.populateAncestorSet();
81
109
  }
82
110
  this.gitAnswered = files !== null;
83
111
  this.activeSetPopulated = includeUntracked && files !== null;
84
112
  this.initialized = true;
113
+ // After the state above is settled, so a throw from the seam could never
114
+ // leave a half-initialized tracker; and charged even when git did not answer,
115
+ // because a failed `git ls-files` still spawned a process and still cost the
116
+ // command the time it took to fail.
117
+ recordSharedPass(CRAWL_SHARED_GIT_TRACKER_ID, startedAt);
118
+ }
119
+ /**
120
+ * The active set read off a snapshot this process has ALREADY taken — or
121
+ * `null` to say "ask git yourself".
122
+ *
123
+ * ## Why this is the same question, not a similar one
124
+ *
125
+ * `git ls-files --cached --others --exclude-standard` returns
126
+ * `tracked ∪ (untracked ∧ ¬ignored)`. A snapshot is `git add --all` **without**
127
+ * `--force` staged into a throwaway index, whose membership is that same set
128
+ * by construction — the exclusion of ignored paths is `--force`'s absence in
129
+ * both cases. So where a snapshot is already in hand, the spawn this method
130
+ * replaces would re-derive a set the process is already holding.
131
+ *
132
+ * It is deliberately a PEEK ({@link peekGitTreeSnapshot}) and never a take: a
133
+ * snapshot costs a `git add --all`, so causing one to avoid an `ls-files`
134
+ * would be a large loss dressed as a saving. On the incumbent walk no snapshot
135
+ * is ever taken, the peek misses, and this tracker spawns exactly as it always
136
+ * has.
137
+ *
138
+ * ## The one divergence, and why it is an improvement
139
+ *
140
+ * `--cached` reads the REAL index, so a tracked file deleted from the working
141
+ * tree is still listed. `git add --all` stages that deletion, so the snapshot
142
+ * omits it. The set is therefore not identical — it is the same set minus
143
+ * paths that do not exist, which is what {@link isIgnoredByActiveSet} already
144
+ * documents the active set to be ("it can only ever contain paths that
145
+ * EXIST"). The snapshot-sourced set honours that sentence more exactly than
146
+ * the spawn does.
147
+ *
148
+ * @param includeUntracked - The caller's requested membership
149
+ * @returns Root-relative paths in `git ls-files` shape, or `null` when no
150
+ * snapshot is available or the request is one a snapshot cannot answer
151
+ */
152
+ activePathsFromOpenSnapshot(includeUntracked) {
153
+ // A snapshot cannot express the tracked-ONLY set: `add --all` stages
154
+ // untracked-not-ignored files too, and nothing in the result marks which
155
+ // entries were already tracked. Declining is the only correct answer.
156
+ if (!includeUntracked)
157
+ return null;
158
+ const snapshot = peekGitTreeSnapshot(this.projectRoot);
159
+ if (snapshot === undefined)
160
+ return null;
161
+ const relativePaths = [];
162
+ for (const entry of snapshot.entries) {
163
+ // A snapshot covers the whole REPOSITORY, which may be an ancestor of this
164
+ // tracker's root — `git ls-files` run at that root scopes its listing to
165
+ // it, so anything above is not this tracker's business and must not enter
166
+ // the active set. Everything below is kept verbatim, symlinks and
167
+ // submodule gitlinks included, because `--cached` lists those too.
168
+ const relativePath = toForwardSlash(safePath.relative(this.normalizedProjectRoot, entry.absolutePath));
169
+ // A leading `..` SEGMENT, not a `../` prefix: compared as a path segment,
170
+ // a file legitimately named `..hidden` is kept rather than silently
171
+ // dropped as if it were an escape.
172
+ if (relativePath.length === 0 || relativePath.split('/')[0] === '..') {
173
+ continue;
174
+ }
175
+ relativePaths.push(relativePath);
176
+ }
177
+ return relativePaths;
85
178
  }
86
179
  /**
87
180
  * Did git actually answer, or is this tracker an empty shell?
@@ -175,8 +268,21 @@ export class GitTracker {
175
268
  * possible per-path spawn.
176
269
  *
177
270
  * @param absolutePath - Absolute path to check
271
+ * @param knownToExist - The caller's own answer to the existence question, when
272
+ * it has already asked. Supplying it skips this method's `existsSync`, which
273
+ * is otherwise paid once per path that is absent from the active set — i.e.
274
+ * once per ignored path, and the projection's `filesystem` extent enumerates
275
+ * all of them (11,108 calls on an 8,496-path adopter tree).
276
+ *
277
+ * **It must mean what `existsSync` means: `stat` succeeds, following
278
+ * symlinks.** A caller holding only an `lstat` result has a DIFFERENT fact —
279
+ * `lstat` succeeds on a dangling symlink where `existsSync` returns false —
280
+ * and must narrow it to `exists && symlinkResolves !== false` rather than
281
+ * pass the `lstat` boolean through, or dangling symlinks silently stop
282
+ * falling back to `git check-ignore` and start reporting as ignored.
283
+ * Omit it and nothing changes.
178
284
  */
179
- isIgnoredByActiveSet(absolutePath) {
285
+ isIgnoredByActiveSet(absolutePath, knownToExist) {
180
286
  if (!this.activeSetPopulated) {
181
287
  return this.isIgnored(absolutePath);
182
288
  }
@@ -191,7 +297,8 @@ export class GitTracker {
191
297
  // Absent from the active set. That means "ignored" only for a path that is
192
298
  // actually there; otherwise the set has no opinion and git must be asked.
193
299
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-supplied path, read-only existence probe
194
- if (!existsSync(normalized)) {
300
+ const present = knownToExist ?? existsSync(normalized);
301
+ if (!present) {
195
302
  return this.isIgnored(absolutePath);
196
303
  }
197
304
  return true;
@@ -228,6 +335,30 @@ export class GitTracker {
228
335
  this.cache.set(cacheKey, ignored);
229
336
  return ignored;
230
337
  }
338
+ /**
339
+ * The spelling git records for a path, or `null` if git has no record of it.
340
+ *
341
+ * This is the casing oracle, not another ignore check. On a case-insensitive
342
+ * filesystem `docs/Readme.md` and `docs/README.md` are one inode with two
343
+ * spellings, and Node's two `realpath` implementations disagree about which
344
+ * one they hand back — so anything that derives an identity from a path needs
345
+ * a single authoritative spelling, and git's is it wherever git has one.
346
+ *
347
+ * The lookup key is lowercased, which is the point: the caller asks with
348
+ * whatever casing it observed and gets back the casing git holds.
349
+ *
350
+ * Answers only from the pre-populated set — never spawns. A path git does not
351
+ * know (untracked-and-ignored, non-existent, outside the project root, or any
352
+ * path at all when `git ls-files` did not answer) returns `null`, and the
353
+ * caller falls back to the on-disk casing.
354
+ *
355
+ * @param absolutePath - Absolute path to look up
356
+ * @returns Root-relative, forward-slashed path as git spells it — relative to
357
+ * THIS tracker's project root — or `null` when git has no record of it
358
+ */
359
+ indexPathFor(absolutePath) {
360
+ return this.indexPaths.get(safePath.resolve(absolutePath).toLowerCase()) ?? null;
361
+ }
231
362
  /**
232
363
  * Get cache statistics.
233
364
  */
@@ -245,6 +376,7 @@ export class GitTracker {
245
376
  this.cache.clear();
246
377
  this.activeSet.clear();
247
378
  this.activeAncestors.clear();
379
+ this.indexPaths.clear();
248
380
  this.initialized = false;
249
381
  this.activeSetPopulated = false;
250
382
  this.gitAnswered = false;
@@ -1 +1 @@
1
- {"version":3,"file":"git-tracker.js","sourceRoot":"","sources":["../src/git-tracker.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"git-tracker.js","sourceRoot":"","sources":["../src/git-tracker.ts"],"names":[],"mappings":"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"}
@@ -43,6 +43,44 @@ export declare function gitLsFiles(options: {
43
43
  patterns?: string[];
44
44
  includeUntracked?: boolean;
45
45
  }): string[] | null;
46
+ /**
47
+ * List paths git does NOT track, with each wholly-untracked directory collapsed
48
+ * to a single entry.
49
+ *
50
+ * This is the **prune list**, not a file list, and the distinction is the whole
51
+ * value. `--others --ignored --exclude-standard` alone returns every ignored file
52
+ * individually: measured on an 8,496-path adopter working tree that is 533,557
53
+ * paths in 1.19 s — worse than the crawl it was meant to replace, because
54
+ * `.turbo/cache` alone contributed 418,518 of them. Adding `--directory`
55
+ * collapses each wholly-ignored directory to one entry: **369 entries in 60 ms**.
56
+ * A caller can then decide per directory whether to descend, and skip a
57
+ * half-million paths by name without ever entering them.
58
+ *
59
+ * A collapsed directory entry is returned with a trailing `/`, exactly as git
60
+ * spells it. That is how a caller tells "this whole subtree" from "this one
61
+ * file", so it is deliberately not normalized away here.
62
+ *
63
+ * @param options - Configuration options
64
+ * @param options.cwd - Working directory inside the repository
65
+ * @param options.ignored - Restrict to ignored paths. Off, the listing is
66
+ * untracked-but-not-ignored paths — which is the only way to see an EMPTY
67
+ * untracked directory, since a directory with no files in it is invisible to
68
+ * `ls-files` and to any tree object
69
+ * @param options.directory - Collapse a wholly-untracked directory to one entry
70
+ * @returns Paths relative to the git root, or null if git did not answer
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * // The prune list: where the ignored territory is, without enumerating it.
75
+ * const prune = gitLsOthers({ cwd: root, ignored: true, directory: true });
76
+ * // → ['dist/', 'node_modules/', '.turbo/', 'notes.local.md']
77
+ * ```
78
+ */
79
+ export declare function gitLsOthers(options: {
80
+ cwd: string;
81
+ ignored?: boolean;
82
+ directory?: boolean;
83
+ }): string[] | null;
46
84
  /**
47
85
  * Check if a file path is ignored by git
48
86
  *
@@ -1 +1 @@
1
- {"version":3,"file":"git-utils.d.ts","sourceRoot":"","sources":["../src/git-utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAYH;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAmB3D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,GAAG,MAAM,EAAE,GAAG,IAAI,CA6ClB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,OAAO,CAkEnF"}
1
+ {"version":3,"file":"git-utils.d.ts","sourceRoot":"","sources":["../src/git-utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAUH;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAmB3D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,GAAG,MAAM,EAAE,GAAG,IAAI,CAiClB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,GAAG,MAAM,EAAE,GAAG,IAAI,CAuBlB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,OAAO,CAiDnF"}
package/dist/git-utils.js CHANGED
@@ -2,11 +2,10 @@
2
2
  * Centralized git command wrapper.
3
3
  * All git commands should go through this module for consistency and testability.
4
4
  */
5
- import { spawnSync } from 'node:child_process';
6
5
  import { existsSync } from 'node:fs';
7
6
  import { dirname, parse } from 'node:path';
8
- import which from 'which';
9
7
  import { lookupGitRoot, rememberGitRoot } from './git-root-cache.js';
8
+ import { runGit } from './git-run.js';
10
9
  import { safePath } from './path-utils.js';
11
10
  /**
12
11
  * Find the git repository root by walking up from the given directory.
@@ -62,44 +61,86 @@ export function gitFindRoot(startDir) {
62
61
  * ```
63
62
  */
64
63
  export function gitLsFiles(options) {
65
- try {
66
- // Resolve git path using which for security (avoids PATH manipulation)
67
- const gitPath = which.sync('git');
68
- // -z emits NUL-separated, UNQUOTED paths regardless of byte content. Without
69
- // it, git quotes any path containing non-ASCII bytes (wraps it in double
70
- // quotes with octal escapes, e.g. `café.md` -> `"caf\303\251.md"`), which is
71
- // unusable to any exact-string lookup against the real filename.
72
- const args = ['ls-files', '-z'];
73
- // Include untracked files that aren't gitignored
74
- if (options.includeUntracked) {
75
- args.push('--cached', '--others', '--exclude-standard');
76
- }
77
- // Add patterns if provided
78
- if (options.patterns && options.patterns.length > 0) {
79
- args.push('--', ...options.patterns);
80
- }
81
- const result = spawnSync(gitPath, args, {
82
- cwd: options.cwd,
83
- encoding: 'utf-8',
84
- stdio: 'pipe',
85
- shell: false, // No shell interpreter for security
86
- });
87
- // Exit code 128 typically means not a git repository
88
- if (result.status === 128 || result.error) {
89
- return null;
90
- }
91
- if (result.status !== 0) {
92
- return null;
93
- }
94
- // Parse output into array of file paths. NUL-separated (from -z above), so
95
- // no path can ever need trimming or quote-unescaping — a trailing NUL just
96
- // produces one empty string at the end, which the filter below drops.
97
- return result.stdout.split('\0').filter((line) => line.length > 0);
64
+ // -z emits NUL-separated, UNQUOTED paths regardless of byte content. Without
65
+ // it, git quotes any path containing non-ASCII bytes (wraps it in double
66
+ // quotes with octal escapes, e.g. `café.md` -> `"caf\303\251.md"`), which is
67
+ // unusable to any exact-string lookup against the real filename.
68
+ const args = ['ls-files', '-z'];
69
+ // Include untracked files that aren't gitignored
70
+ if (options.includeUntracked) {
71
+ args.push('--cached', '--others', '--exclude-standard');
72
+ }
73
+ // Add patterns if provided
74
+ if (options.patterns && options.patterns.length > 0) {
75
+ args.push('--', ...options.patterns);
76
+ }
77
+ // `trim: false` because the output is NUL-delimited: git sorts by byte value,
78
+ // so a path beginning with a space sorts FIRST and a trim would silently
79
+ // rename it to a path that does not exist.
80
+ const result = runGit(args, { cwd: options.cwd, trim: false });
81
+ // Every failure means the same thing to this caller — no listing. That covers
82
+ // "not a repository" (128), git missing entirely, and a listing too large for
83
+ // the buffer, which `ok` folds in because a truncated answer here is files
84
+ // silently missing rather than a short list.
85
+ if (!result.ok) {
86
+ return null;
87
+ }
88
+ // NUL-separated (from -z above), so no path can ever need quote-unescaping —
89
+ // a trailing NUL just produces one empty string at the end, dropped below.
90
+ return result.stdout.split('\0').filter((line) => line.length > 0);
91
+ }
92
+ /**
93
+ * List paths git does NOT track, with each wholly-untracked directory collapsed
94
+ * to a single entry.
95
+ *
96
+ * This is the **prune list**, not a file list, and the distinction is the whole
97
+ * value. `--others --ignored --exclude-standard` alone returns every ignored file
98
+ * individually: measured on an 8,496-path adopter working tree that is 533,557
99
+ * paths in 1.19 s — worse than the crawl it was meant to replace, because
100
+ * `.turbo/cache` alone contributed 418,518 of them. Adding `--directory`
101
+ * collapses each wholly-ignored directory to one entry: **369 entries in 60 ms**.
102
+ * A caller can then decide per directory whether to descend, and skip a
103
+ * half-million paths by name without ever entering them.
104
+ *
105
+ * A collapsed directory entry is returned with a trailing `/`, exactly as git
106
+ * spells it. That is how a caller tells "this whole subtree" from "this one
107
+ * file", so it is deliberately not normalized away here.
108
+ *
109
+ * @param options - Configuration options
110
+ * @param options.cwd - Working directory inside the repository
111
+ * @param options.ignored - Restrict to ignored paths. Off, the listing is
112
+ * untracked-but-not-ignored paths — which is the only way to see an EMPTY
113
+ * untracked directory, since a directory with no files in it is invisible to
114
+ * `ls-files` and to any tree object
115
+ * @param options.directory - Collapse a wholly-untracked directory to one entry
116
+ * @returns Paths relative to the git root, or null if git did not answer
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * // The prune list: where the ignored territory is, without enumerating it.
121
+ * const prune = gitLsOthers({ cwd: root, ignored: true, directory: true });
122
+ * // → ['dist/', 'node_modules/', '.turbo/', 'notes.local.md']
123
+ * ```
124
+ */
125
+ export function gitLsOthers(options) {
126
+ // `--exclude-standard` is unconditional: `--ignored` is rejected outright by
127
+ // git without an exclude source, and without it the not-ignored listing would
128
+ // report every ignored path as merely untracked — the exact inversion of what
129
+ // either caller asked for.
130
+ const args = ['ls-files', '-z', '--others', '--exclude-standard'];
131
+ if (options.ignored) {
132
+ args.push('--ignored');
133
+ }
134
+ if (options.directory) {
135
+ args.push('--directory');
98
136
  }
99
- catch {
100
- // Git not available or other error
137
+ // See `gitLsFiles`: NUL-delimited output must not be trimmed, or a path
138
+ // beginning with a space which git sorts FIRST — comes back renamed.
139
+ const result = runGit(args, { cwd: options.cwd, trim: false });
140
+ if (!result.ok) {
101
141
  return null;
102
142
  }
143
+ return result.stdout.split('\0').filter((line) => line.length > 0);
103
144
  }
104
145
  /**
105
146
  * Check if a file path is ignored by git
@@ -137,51 +178,33 @@ export function isGitIgnored(filePath, cwd = process.cwd()) {
137
178
  if (gitFindRoot(cwd) === null) {
138
179
  return false;
139
180
  }
140
- try {
141
- // Resolve git path using which for security (avoids PATH manipulation)
142
- const gitPath = which.sync('git');
143
- const checkIgnoreArgs = ['check-ignore', '-q'];
144
- // git check-ignore returns exit code 0 if file is ignored, 1 if not
145
- const result = spawnSync(gitPath, [...checkIgnoreArgs, filePath], {
146
- cwd,
147
- encoding: 'utf-8',
148
- stdio: 'pipe',
149
- shell: false, // No shell interpreter for security
150
- });
151
- if (result.status === 0) {
152
- return true;
153
- }
154
- // Exit code 128 = fatal error (e.g., path beyond a symbolic link).
155
- // Walk up ancestor directories to check if a parent is gitignored.
156
- // Example: data/ is gitignored, data/symlink/deep/file.md fails with 128,
157
- // but checking data/ directly returns 0.
158
- if (result.status !== 1) {
159
- const resolvedCwd = safePath.resolve(cwd);
160
- const resolvedFile = safePath.resolve(cwd, filePath);
161
- let current = dirname(resolvedFile);
162
- while (current !== resolvedCwd && !current.endsWith(parse(current).root)) {
163
- const ancestorResult = spawnSync(gitPath, [...checkIgnoreArgs, current], {
164
- cwd,
165
- encoding: 'utf-8',
166
- stdio: 'pipe',
167
- shell: false,
168
- });
169
- if (ancestorResult.status === 0) {
170
- return true;
171
- }
172
- // If this ancestor check also fails fatally, keep walking up
173
- // If it returns 1 (not ignored), the parent is tracked — stop walking
174
- if (ancestorResult.status === 1) {
175
- break;
176
- }
177
- current = dirname(current);
181
+ const checkIgnoreArgs = ['check-ignore', '-q'];
182
+ // git check-ignore returns exit code 0 if file is ignored, 1 if not
183
+ const result = runGit([...checkIgnoreArgs, filePath], { cwd });
184
+ if (result.status === 0) {
185
+ return true;
186
+ }
187
+ // Exit code 128 = fatal error (e.g., path beyond a symbolic link).
188
+ // Walk up ancestor directories to check if a parent is gitignored.
189
+ // Example: data/ is gitignored, data/symlink/deep/file.md fails with 128,
190
+ // but checking data/ directly returns 0.
191
+ if (result.status !== 1) {
192
+ const resolvedCwd = safePath.resolve(cwd);
193
+ const resolvedFile = safePath.resolve(cwd, filePath);
194
+ let current = dirname(resolvedFile);
195
+ while (current !== resolvedCwd && !current.endsWith(parse(current).root)) {
196
+ const ancestorResult = runGit([...checkIgnoreArgs, current], { cwd });
197
+ if (ancestorResult.status === 0) {
198
+ return true;
199
+ }
200
+ // If this ancestor check also fails fatally, keep walking up
201
+ // If it returns 1 (not ignored), the parent is tracked — stop walking
202
+ if (ancestorResult.status === 1) {
203
+ break;
178
204
  }
205
+ current = dirname(current);
179
206
  }
180
- return false;
181
- }
182
- catch {
183
- // If git is not available or other error, assume not ignored
184
- return false;
185
207
  }
208
+ return false;
186
209
  }
187
210
  //# sourceMappingURL=git-utils.js.map