@aaroncql/pim-agent 0.1.0 → 0.3.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 (38) hide show
  1. package/README.md +92 -65
  2. package/package.json +6 -6
  3. package/src/extensions/apply-patch/coordinator.ts +67 -0
  4. package/src/extensions/apply-patch/executor.ts +566 -0
  5. package/src/extensions/apply-patch/index.ts +75 -0
  6. package/src/extensions/apply-patch/matcher.ts +66 -0
  7. package/src/extensions/apply-patch/model.ts +34 -0
  8. package/src/extensions/apply-patch/parser.ts +381 -0
  9. package/src/extensions/apply-patch/render.ts +261 -0
  10. package/src/extensions/apply-patch/schema.ts +43 -0
  11. package/src/extensions/apply-patch/types.ts +30 -0
  12. package/src/extensions/bash/index.ts +3 -3
  13. package/src/extensions/edit/index.ts +2 -1
  14. package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
  15. package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
  16. package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
  17. package/src/extensions/file-picker/catalog.ts +38 -33
  18. package/src/extensions/file-picker/filePickerWorker.ts +72 -0
  19. package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
  20. package/src/extensions/file-picker/index.ts +138 -83
  21. package/src/extensions/file-picker/ranker.ts +180 -12
  22. package/src/extensions/glob/index.ts +3 -1
  23. package/src/extensions/glob/schema.ts +2 -1
  24. package/src/extensions/grep/grep.ts +45 -2
  25. package/src/extensions/grep/index.ts +3 -1
  26. package/src/extensions/grep/render.ts +18 -4
  27. package/src/extensions/grep/schema.ts +1 -1
  28. package/src/extensions/read/index.ts +36 -9
  29. package/src/extensions/read/render.ts +31 -3
  30. package/src/extensions/subagent/index.ts +4 -1
  31. package/src/extensions/todo/index.ts +4 -3
  32. package/src/extensions/web-search/index.ts +2 -1
  33. package/src/extensions/write/index.ts +2 -1
  34. package/src/shared/FileEnumerator.ts +492 -0
  35. package/src/shared/FileScanner.ts +15 -17
  36. package/src/shared/PatchSummary.ts +82 -0
  37. package/src/telegram/Renderer.ts +190 -4
  38. package/src/shared/GitignoreFilter.ts +0 -142
@@ -3,18 +3,23 @@ import { Paths } from "../../shared/Paths";
3
3
  import { Renderer } from "../../shared/Renderer";
4
4
  import { Tools } from "../../shared/Tools";
5
5
  import { buildReadRange, readFile } from "./read";
6
- import { formatTitlePath } from "./render";
6
+ import { renderTitlePath, type ReadTitleOutcome } from "./render";
7
7
  import { type ReadInput, readSchema } from "./schema";
8
8
 
9
9
  const PREVIEW_LINES = 10;
10
10
 
11
+ type ReadRenderState = {
12
+ outcome?: ReadTitleOutcome;
13
+ };
14
+
11
15
  export default function (pi: ExtensionAPI): void {
12
16
  Tools.register(pi, {
13
17
  name: "read",
14
18
  label: "read",
15
19
  description:
16
- "Read a local UTF-8 text file. Output is `LINE:CONTENT` with no space after the colon. Capped at 32KB per call; lines longer than 2000 chars are truncated.",
17
- promptSnippet: "Read text files.",
20
+ "Read a local UTF-8 text file. " +
21
+ "Output is `LINE:CONTENT` with no space after the colon. " +
22
+ "Capped at 32KB per call; lines longer than 2000 chars are truncated.",
18
23
  parameters: readSchema,
19
24
  renderShell: "self",
20
25
  executionMode: "parallel",
@@ -58,12 +63,17 @@ export default function (pi: ExtensionAPI): void {
58
63
  },
59
64
  renderCall(args, theme, context) {
60
65
  const input = (args ?? {}) as Partial<ReadInput>;
61
- const title = formatTitlePath({
62
- path: input.path,
63
- cwd: context.cwd,
64
- start: input.start,
65
- end: input.end,
66
- });
66
+ const state = context.state as ReadRenderState;
67
+ const title = renderTitlePath(
68
+ {
69
+ path: input.path,
70
+ cwd: context.cwd,
71
+ start: input.start,
72
+ end: input.end,
73
+ outcome: state.outcome,
74
+ },
75
+ theme
76
+ );
67
77
  return Renderer.renderToolCallTitle({
68
78
  label: "Read",
69
79
  title,
@@ -72,6 +82,23 @@ export default function (pi: ExtensionAPI): void {
72
82
  });
73
83
  },
74
84
  renderResult(result, options, theme, context) {
85
+ const state = context.state as ReadRenderState;
86
+
87
+ if (!options.isPartial && state.outcome === undefined) {
88
+ const details = result.details as ReadTitleOutcome | undefined;
89
+
90
+ if (
91
+ typeof details?.visibleStart === "number" &&
92
+ typeof details.visibleEnd === "number"
93
+ ) {
94
+ state.outcome = {
95
+ visibleStart: details.visibleStart,
96
+ visibleEnd: details.visibleEnd,
97
+ };
98
+ context.invalidate();
99
+ }
100
+ }
101
+
75
102
  return Renderer.renderBorderedResult({
76
103
  result,
77
104
  options,
@@ -1,24 +1,52 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
1
2
  import { Paths } from "../../shared/Paths";
2
3
 
4
+ export type ReadTitleOutcome = {
5
+ readonly visibleStart: number;
6
+ readonly visibleEnd: number;
7
+ };
8
+
3
9
  export type TitlePathOptions = {
4
10
  readonly path: string | undefined;
5
11
  readonly cwd: string;
6
12
  readonly start: number | undefined;
7
13
  readonly end: number | undefined;
14
+ readonly outcome?: ReadTitleOutcome;
8
15
  };
9
16
 
10
17
  export function formatTitlePath(options: TitlePathOptions): string {
18
+ const { path, range } = formatTitlePathParts(options);
19
+ return `${path}${range}`;
20
+ }
21
+
22
+ export function renderTitlePath(
23
+ options: TitlePathOptions,
24
+ theme: Theme
25
+ ): string {
26
+ const { path, range } = formatTitlePathParts(options);
27
+ return `${path}${range === "" ? "" : theme.fg("muted", range)}`;
28
+ }
29
+
30
+ function formatTitlePathParts(options: TitlePathOptions): {
31
+ readonly path: string;
32
+ readonly range: string;
33
+ } {
11
34
  const path = options.path
12
35
  ? Paths.displayRelative(options.path, options.cwd)
13
36
  : "...";
14
- const range = formatRange(options.start, options.end);
15
- return `${path}${range}`;
37
+ const range = formatRange(options.start, options.end, options.outcome);
38
+ return { path, range };
16
39
  }
17
40
 
18
41
  function formatRange(
19
42
  start: number | undefined,
20
- end: number | undefined
43
+ end: number | undefined,
44
+ outcome: ReadTitleOutcome | undefined
21
45
  ): string {
46
+ if (outcome !== undefined) {
47
+ return `:${outcome.visibleStart}-${outcome.visibleEnd}`;
48
+ }
49
+
22
50
  if (start === undefined && end === undefined) {
23
51
  return "";
24
52
  }
@@ -9,7 +9,10 @@ export default function (pi: ExtensionAPI): void {
9
9
  name: "subagent",
10
10
  label: "subagent",
11
11
  description:
12
- "Delegate a task to an isolated subagent to keep your main context clean. The subagent inherits your currently active tools (except subagent itself) and runs in a fresh in-memory session. Multiple subagent calls in one turn run in parallel. Subagent responses are capped at 32KB; the full output is preserved in tool details.",
12
+ "Run a task in an isolated subagent with a fresh context. " +
13
+ "The subagent inherits the currently active tools, except subagent itself. " +
14
+ "Multiple subagent calls in one turn run in parallel. " +
15
+ "Subagent output returned to the main agent is capped at 32KB.",
13
16
  parameters: subagentSchema,
14
17
  renderShell: "self",
15
18
  executionMode: "parallel",
@@ -61,9 +61,10 @@ export default function (pi: ExtensionAPI): void {
61
61
  name: "todo",
62
62
  label: "todo",
63
63
  description:
64
- "Manage your to-dos. ALWAYS use for tasks with 3+ steps; skip only for trivial one-step tasks. " +
65
- "Each call replaces the entire list; include every item in priority order. " +
66
- "Keep at most one item in_progress, mark items completed immediately after finishing, and preserve skipped work as cancelled.",
64
+ "Update the session task list. " +
65
+ "Each call replaces the entire list; include every item that should remain, in priority order. " +
66
+ "Status values are pending, in_progress, completed, and cancelled. " +
67
+ "At most one item may be in_progress.",
67
68
  parameters: todoSchema,
68
69
  renderShell: "self",
69
70
  executionMode: "sequential",
@@ -49,7 +49,8 @@ export default function (pi: ExtensionAPI): void {
49
49
  name: "web_search",
50
50
  label: "web_search",
51
51
  description:
52
- "Search the web. Returns ranked results with title, URL, and a short snippet.",
52
+ "Search the web. " +
53
+ "Returns ranked results with title, URL, and a short snippet.",
53
54
  parameters: webSearchSchema,
54
55
  renderShell: "self",
55
56
  executionMode: "parallel",
@@ -12,7 +12,8 @@ export default function (pi: ExtensionAPI): void {
12
12
  name: "write",
13
13
  label: "write",
14
14
  description:
15
- "Create or overwrite UTF-8 text files. Use write only for new files or full rewrites - prefer edit for changes to existing files.",
15
+ "Create or overwrite UTF-8 text files. " +
16
+ "Use write only for new files or full rewrites.",
16
17
  parameters: writeSchema,
17
18
  renderShell: "self",
18
19
  executionMode: "sequential",
@@ -0,0 +1,492 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import ignore, { type Ignore } from "ignore";
4
+
5
+ /**
6
+ * Max in-flight `readdir` syscalls.
7
+ */
8
+ const CONCURRENCY = 32;
9
+
10
+ /**
11
+ * Options that decide what the walk enumerates.
12
+ */
13
+ export type EnumerateOptions = {
14
+ /**
15
+ * Include dot-prefixed files/dirs such as `.env`, `.github`. Default false.
16
+ */
17
+ readonly includeDotfiles?: boolean;
18
+ /**
19
+ * Include gitignored / normally-ignored paths such as `node_modules`. Default false.
20
+ */
21
+ readonly includeIgnored?: boolean;
22
+ /**
23
+ * Emit directories as well as files, each marked with a trailing `/`. Default false.
24
+ */
25
+ readonly includeDirectories?: boolean;
26
+ };
27
+
28
+ type StackEntry = {
29
+ /**
30
+ * Absolute path of the directory.
31
+ */
32
+ abs: string;
33
+ /**
34
+ * Root-relative POSIX path of the directory ("" for root), no trailing slash.
35
+ */
36
+ rel: string;
37
+ /**
38
+ * Whether this directory lies within a git repository. When false, no
39
+ * `.gitignore` files are honored — matching git/fd, which treat ignore files
40
+ * as inert outside a repository.
41
+ */
42
+ inRepo: boolean;
43
+ /**
44
+ * Absolute path of the git repository root the ignore rules are anchored to.
45
+ * Paths are tested relative to this. Only meaningful when `inRepo`.
46
+ */
47
+ repoRootAbs: string;
48
+ /**
49
+ * Every gitignore pattern that applies to this subtree, ordered shallowest
50
+ * (repo root) to deepest, each already re-anchored to be relative to
51
+ * `repoRootAbs`. Held so a nested `.gitignore` can extend it without losing
52
+ * the ancestor rules. Empty when `inRepo` is false.
53
+ *
54
+ * Keeping all of a repo's rules in a single matcher (rather than one matcher
55
+ * per `.gitignore`) is what lets negations work across files: a `build/`
56
+ * exclusion at the repo root and a `!build/` re-inclusion in a nested
57
+ * `.gitignore` are only resolved correctly when evaluated together.
58
+ */
59
+ ignoreRules: string[];
60
+ /**
61
+ * Matcher built from `ignoreRules`; tests paths relative to `repoRootAbs`.
62
+ */
63
+ matcher: Ignore;
64
+ };
65
+
66
+ /** Reused for directories outside any repo, where no rules apply. */
67
+ const EMPTY_MATCHER = ignore();
68
+
69
+ /**
70
+ * Shared mutable state threaded through one `enumerate` walk.
71
+ */
72
+ type WalkContext = {
73
+ includeDotfiles: boolean;
74
+ includeDirectories: boolean;
75
+ useIgnore: boolean;
76
+ globalGitIgnore: string | undefined;
77
+ stack: StackEntry[];
78
+ result: string[];
79
+ };
80
+
81
+ async function readIgnoreFile(path: string): Promise<string | undefined> {
82
+ try {
83
+ return await Bun.file(path).text();
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ function globalGitIgnorePath(): string | undefined {
90
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
91
+ if (xdgConfigHome !== undefined && xdgConfigHome !== "") {
92
+ return join(xdgConfigHome, "git", "ignore");
93
+ }
94
+
95
+ const home = process.env.HOME;
96
+ if (home !== undefined && home !== "") {
97
+ return join(home, ".config", "git", "ignore");
98
+ }
99
+
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * Path of `absPath` relative to `baseAbs` (POSIX), or undefined if `absPath`
105
+ * is not within `baseAbs`. Returns "" when they are the same directory.
106
+ */
107
+ function relFromBase(absPath: string, baseAbs: string): string | undefined {
108
+ if (absPath === baseAbs) {
109
+ return "";
110
+ }
111
+
112
+ const prefix = baseAbs.endsWith("/") ? baseAbs : `${baseAbs}/`;
113
+ if (!absPath.startsWith(prefix)) {
114
+ return undefined;
115
+ }
116
+
117
+ return absPath.slice(prefix.length);
118
+ }
119
+
120
+ /**
121
+ * Verdict for one path against a repo's combined matcher. `ignored` is the net
122
+ * decision; `unignored` is true when a `!` negation rule was the last to match,
123
+ * i.e. the path is explicitly re-included. The two are distinct because a
124
+ * negation can re-include a path that would otherwise be hidden as a dotfile.
125
+ */
126
+ function ignoreVerdict(
127
+ matcher: Ignore,
128
+ repoRootAbs: string,
129
+ absPath: string,
130
+ isDirectory: boolean
131
+ ): { ignored: boolean; unignored: boolean } {
132
+ const path = relFromBase(absPath, repoRootAbs);
133
+ if (path === undefined || path === "") {
134
+ return { ignored: false, unignored: false };
135
+ }
136
+ const result = matcher.test(isDirectory ? `${path}/` : path);
137
+ return { ignored: result.ignored, unignored: result.unignored };
138
+ }
139
+
140
+ /**
141
+ * Append the non-empty, non-comment lines of `content` to `out` verbatim. Used
142
+ * for rules already anchored at the repo root (the repo's own `.gitignore`,
143
+ * `.git/info/exclude`, and global excludes).
144
+ */
145
+ function pushRules(out: string[], content: string): void {
146
+ for (const line of content.split(/\r?\n/)) {
147
+ if (line.trim() === "" || line.startsWith("#")) {
148
+ continue;
149
+ }
150
+ out.push(line);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Re-anchor each pattern of a nested `.gitignore` so it is relative to the repo
156
+ * root rather than to the `.gitignore`'s own directory, appending to `out`.
157
+ * `basePrefix` is the `.gitignore`'s repo-root-relative directory path with a
158
+ * trailing slash (e.g. `"engine/src/"`).
159
+ *
160
+ * Mirrors gitignore anchoring rules: a pattern with a leading slash, or one
161
+ * containing a non-trailing slash, is anchored to the `.gitignore`'s directory,
162
+ * so it just gains the prefix; a pattern with no slash (or only a trailing one)
163
+ * matches at any depth below that directory, so it gains a "prefix + doubled
164
+ * star + slash" lead-in to match through intervening directories.
165
+ */
166
+ function reanchorRules(
167
+ content: string,
168
+ basePrefix: string,
169
+ out: string[]
170
+ ): void {
171
+ for (const line of content.split(/\r?\n/)) {
172
+ if (line.trim() === "" || line.startsWith("#")) {
173
+ continue;
174
+ }
175
+
176
+ let negated = false;
177
+ let pattern = line;
178
+ if (pattern.startsWith("!")) {
179
+ negated = true;
180
+ pattern = pattern.slice(1);
181
+ }
182
+ if (pattern === "") {
183
+ continue;
184
+ }
185
+
186
+ let body: string;
187
+ if (pattern.startsWith("/")) {
188
+ body = basePrefix + pattern.slice(1);
189
+ } else {
190
+ const core = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
191
+ body = core.includes("/")
192
+ ? basePrefix + pattern
193
+ : basePrefix + "**/" + pattern;
194
+ }
195
+
196
+ out.push(negated ? `!${body}` : body);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Nearest ancestor of `start` (inclusive) that contains a `.git` entry, i.e.
202
+ * the git repository owning `start`, or undefined if `start` is not in a repo.
203
+ * A `.git` may be a directory (normal clone) or a file (worktree/submodule).
204
+ */
205
+ async function findRepoRoot(start: string): Promise<string | undefined> {
206
+ let dir = start;
207
+ for (;;) {
208
+ if (await Bun.file(join(dir, ".git")).exists()) {
209
+ return dir;
210
+ }
211
+ const parent = dirname(dir);
212
+ if (parent === dir) {
213
+ return undefined;
214
+ }
215
+ dir = parent;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Build the base rule set for a repository rooted at `repoAbs`: global excludes,
221
+ * then .git/info/exclude, then the repo root's own .gitignore — appended in that
222
+ * precedence order. All are anchored at the repo root already, so no
223
+ * re-anchoring is needed.
224
+ */
225
+ async function repoBaseRules(
226
+ repoAbs: string,
227
+ globalGitIgnore: string | undefined
228
+ ): Promise<string[]> {
229
+ const rules: string[] = [];
230
+ if (globalGitIgnore !== undefined) {
231
+ pushRules(rules, globalGitIgnore);
232
+ }
233
+ const [infoExclude, repoGitIgnore] = await Promise.all([
234
+ readIgnoreFile(join(repoAbs, ".git", "info", "exclude")),
235
+ readIgnoreFile(join(repoAbs, ".gitignore")),
236
+ ]);
237
+ if (infoExclude !== undefined) {
238
+ pushRules(rules, infoExclude);
239
+ }
240
+ if (repoGitIgnore !== undefined) {
241
+ pushRules(rules, repoGitIgnore);
242
+ }
243
+ return rules;
244
+ }
245
+
246
+ /**
247
+ * Read one directory: collect its files and queue its subdirectories, honoring
248
+ * ignore rules.
249
+ */
250
+ async function processDir(
251
+ ctx: WalkContext,
252
+ currentDir: StackEntry
253
+ ): Promise<void> {
254
+ let entries;
255
+ try {
256
+ entries = await readdir(currentDir.abs, { withFileTypes: true });
257
+ } catch {
258
+ // Unreadable directory (permissions, race): skip it.
259
+ return;
260
+ }
261
+
262
+ let inRepo = currentDir.inRepo;
263
+ let repoRootAbs = currentDir.repoRootAbs;
264
+ let rules = currentDir.ignoreRules;
265
+ let matcher = currentDir.matcher;
266
+
267
+ if (ctx.useIgnore) {
268
+ const hasDotGit = entries.some((e) => e.name === ".git");
269
+ if (hasDotGit) {
270
+ // A .git here marks a repository boundary. Start a fresh rule set for
271
+ // this repo, discarding any inherited (parent-repo) rules.
272
+ inRepo = true;
273
+ repoRootAbs = currentDir.abs;
274
+ rules = await repoBaseRules(currentDir.abs, ctx.globalGitIgnore);
275
+ matcher = ignore().add(rules);
276
+ } else if (inRepo) {
277
+ // Within a repo, a nested .gitignore extends the rules for this subtree.
278
+ const hasGitIgnore = entries.some(
279
+ (e) => e.isFile() && e.name === ".gitignore"
280
+ );
281
+ if (hasGitIgnore) {
282
+ const content = await readIgnoreFile(
283
+ join(currentDir.abs, ".gitignore")
284
+ );
285
+ if (content !== undefined) {
286
+ const dirRel = relFromBase(currentDir.abs, repoRootAbs);
287
+ const next = rules.slice();
288
+ if (dirRel === undefined || dirRel === "") {
289
+ pushRules(next, content);
290
+ } else {
291
+ reanchorRules(content, `${dirRel}/`, next);
292
+ }
293
+ rules = next;
294
+ matcher = ignore().add(rules);
295
+ }
296
+ }
297
+ }
298
+ // When !inRepo, .gitignore files are not honored: rules stays empty.
299
+ }
300
+
301
+ for (const entry of entries) {
302
+ const name = entry.name;
303
+ const relPath = currentDir.rel === "" ? name : `${currentDir.rel}/${name}`;
304
+ const isDir = entry.isDirectory();
305
+ const isSymlink = entry.isSymbolicLink();
306
+
307
+ // Pruned regardless of includeDotfiles.
308
+ if (name === ".git") {
309
+ continue;
310
+ }
311
+
312
+ // Skip the join unless the abs path is actually needed below.
313
+ const needsAbs = isDir || (ctx.useIgnore && inRepo);
314
+ const childAbs = needsAbs ? join(currentDir.abs, name) : "";
315
+
316
+ // One ignore evaluation per entry, reused for both the dotfile rule and the
317
+ // ignore prune below.
318
+ const verdict =
319
+ ctx.useIgnore && inRepo
320
+ ? ignoreVerdict(matcher, repoRootAbs, childAbs, isDir)
321
+ : { ignored: false, unignored: false };
322
+
323
+ // Dot-prefixed entries are hidden by default, but — like git/fd — an
324
+ // explicit `!` negation in a .gitignore re-includes them.
325
+ if (
326
+ !ctx.includeDotfiles &&
327
+ name.charCodeAt(0) === 0x2e /* "." */ &&
328
+ !verdict.unignored
329
+ ) {
330
+ continue;
331
+ }
332
+
333
+ if (verdict.ignored) {
334
+ continue;
335
+ }
336
+
337
+ if (isDir) {
338
+ // Do not follow symlinked dirs, to avoid cycles.
339
+ if (isSymlink) {
340
+ continue;
341
+ }
342
+
343
+ if (ctx.includeDirectories) {
344
+ ctx.result.push(`${relPath}/`);
345
+ }
346
+
347
+ ctx.stack.push({
348
+ abs: childAbs,
349
+ rel: relPath,
350
+ inRepo,
351
+ repoRootAbs,
352
+ ignoreRules: rules,
353
+ matcher,
354
+ });
355
+ continue;
356
+ }
357
+
358
+ if (entry.isFile() || isSymlink) {
359
+ ctx.result.push(relPath);
360
+ continue;
361
+ }
362
+
363
+ // Other dirent types (fifo, socket, block/char device) are ignored.
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Bounded-concurrency pump: keep up to CONCURRENCY `processDir` calls in
369
+ * flight. Each completion refills freed slots from the shared stack, which
370
+ * grows as directories are discovered. A simple worker loop can't be used
371
+ * here: at startup the stack holds only the root, so all but one worker
372
+ * would drain it and exit before any children were pushed.
373
+ */
374
+ function drain(ctx: WalkContext): Promise<void> {
375
+ let inFlight = 0;
376
+ return new Promise<void>((resolve, reject) => {
377
+ // Self-referential to refill slots on each completion; must close over
378
+ // the executor's resolve/reject, so it stays nested here.
379
+ const pump = (): void => {
380
+ while (inFlight < CONCURRENCY && ctx.stack.length > 0) {
381
+ const currentDir = ctx.stack.pop()!;
382
+ inFlight++;
383
+ processDir(ctx, currentDir).then(() => {
384
+ inFlight--;
385
+ pump();
386
+ }, reject);
387
+ }
388
+ if (inFlight === 0 && ctx.stack.length === 0) {
389
+ resolve();
390
+ }
391
+ };
392
+ pump();
393
+ });
394
+ }
395
+
396
+ export class FileEnumerator {
397
+ /**
398
+ * Enumerate all files under `root` as an array of root-relative POSIX paths.
399
+ *
400
+ * Descent is async with a bounded concurrency cap: up to `CONCURRENCY`
401
+ * `readdir` syscalls are in flight at once, all pulling from a shared stack,
402
+ * so directory-read latency overlaps instead of running one-at-a-time.
403
+ *
404
+ * Gitignore handling is repo-aware, matching git/fd: a `.gitignore` is only
405
+ * honored within a git repository, each nested `.git` is a boundary that
406
+ * resets the ignore scope (a child repo does not inherit its parent's rules),
407
+ * and if `root` itself sits inside a repository the enclosing rules are
408
+ * seeded. A `.gitignore` with no enclosing repo is inert, matching git/fd.
409
+ */
410
+ public static async enumerate(
411
+ root: string,
412
+ opts?: EnumerateOptions
413
+ ): Promise<string[]> {
414
+ const includeDotfiles = opts?.includeDotfiles ?? false;
415
+ const includeIgnored = opts?.includeIgnored ?? false;
416
+ const includeDirectories = opts?.includeDirectories ?? false;
417
+ const useIgnore = !includeIgnored;
418
+
419
+ // Global excludes (core.excludesFile / XDG). Read once; applies only within
420
+ // a repository, anchored as if it were a .gitignore at the repo root.
421
+ let globalGitIgnore: string | undefined;
422
+ if (useIgnore) {
423
+ const pathname = globalGitIgnorePath();
424
+ globalGitIgnore =
425
+ pathname === undefined ? undefined : await readIgnoreFile(pathname);
426
+ }
427
+
428
+ // Seed rules from any repository that ENCLOSES `root` (its .git lives at an
429
+ // ancestor of root). The repo root's base rules plus every intermediate
430
+ // .gitignore between it and root are applied. Root's own .gitignore, if any,
431
+ // is added by processDir when root is walked. When root is itself a repo
432
+ // root (or not in a repo at all), processDir handles it from a clean slate.
433
+ let initialInRepo = false;
434
+ let initialRepoRootAbs = root;
435
+ let initialRules: string[] = [];
436
+ if (useIgnore) {
437
+ const repoRoot = await findRepoRoot(root);
438
+ if (repoRoot !== undefined && repoRoot !== root) {
439
+ initialInRepo = true;
440
+ initialRepoRootAbs = repoRoot;
441
+ initialRules = await repoBaseRules(repoRoot, globalGitIgnore);
442
+
443
+ // Intermediate dirs strictly between repoRoot and root, shallowest first.
444
+ const intermediates: string[] = [];
445
+ let dir = dirname(root);
446
+ while (dir !== repoRoot && dir.length > repoRoot.length) {
447
+ intermediates.push(dir);
448
+ const parent = dirname(dir);
449
+ if (parent === dir) {
450
+ break;
451
+ }
452
+ dir = parent;
453
+ }
454
+ intermediates.reverse();
455
+
456
+ for (const dirAbs of intermediates) {
457
+ const content = await readIgnoreFile(join(dirAbs, ".gitignore"));
458
+ if (content !== undefined) {
459
+ const dirRel = relFromBase(dirAbs, repoRoot);
460
+ if (dirRel === undefined || dirRel === "") {
461
+ pushRules(initialRules, content);
462
+ } else {
463
+ reanchorRules(content, `${dirRel}/`, initialRules);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ const ctx: WalkContext = {
471
+ includeDotfiles,
472
+ includeDirectories,
473
+ useIgnore,
474
+ globalGitIgnore,
475
+ stack: [
476
+ {
477
+ abs: root,
478
+ rel: "",
479
+ inRepo: initialInRepo,
480
+ repoRootAbs: initialRepoRootAbs,
481
+ ignoreRules: initialRules,
482
+ matcher: initialInRepo ? ignore().add(initialRules) : EMPTY_MATCHER,
483
+ },
484
+ ],
485
+ result: [],
486
+ };
487
+
488
+ await drain(ctx);
489
+
490
+ return ctx.result;
491
+ }
492
+ }