@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.
- package/README.md +92 -65
- package/package.json +6 -6
- package/src/extensions/apply-patch/coordinator.ts +67 -0
- package/src/extensions/apply-patch/executor.ts +566 -0
- package/src/extensions/apply-patch/index.ts +75 -0
- package/src/extensions/apply-patch/matcher.ts +66 -0
- package/src/extensions/apply-patch/model.ts +34 -0
- package/src/extensions/apply-patch/parser.ts +381 -0
- package/src/extensions/apply-patch/render.ts +261 -0
- package/src/extensions/apply-patch/schema.ts +43 -0
- package/src/extensions/apply-patch/types.ts +30 -0
- package/src/extensions/bash/index.ts +3 -3
- package/src/extensions/edit/index.ts +2 -1
- package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
- package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/src/extensions/file-picker/catalog.ts +38 -33
- package/src/extensions/file-picker/filePickerWorker.ts +72 -0
- package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
- package/src/extensions/file-picker/index.ts +138 -83
- package/src/extensions/file-picker/ranker.ts +180 -12
- package/src/extensions/glob/index.ts +3 -1
- package/src/extensions/glob/schema.ts +2 -1
- package/src/extensions/grep/grep.ts +45 -2
- package/src/extensions/grep/index.ts +3 -1
- package/src/extensions/grep/render.ts +18 -4
- package/src/extensions/grep/schema.ts +1 -1
- package/src/extensions/read/index.ts +36 -9
- package/src/extensions/read/render.ts +31 -3
- package/src/extensions/subagent/index.ts +4 -1
- package/src/extensions/todo/index.ts +4 -3
- package/src/extensions/web-search/index.ts +2 -1
- package/src/extensions/write/index.ts +2 -1
- package/src/shared/FileEnumerator.ts +492 -0
- package/src/shared/FileScanner.ts +15 -17
- package/src/shared/PatchSummary.ts +82 -0
- package/src/telegram/Renderer.ts +190 -4
- 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 {
|
|
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.
|
|
17
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
65
|
-
"Each call replaces the entire list; include every item in priority order. " +
|
|
66
|
-
"
|
|
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.
|
|
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.
|
|
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
|
+
}
|