Skip to content

perf(glob): prune ignored directories during traversal, not just post-filter - #6123

Merged
wenshao merged 7 commits into
QwenLM:mainfrom
minmax:fix/glob-ignore-before-traversal
Jul 4, 2026
Merged

perf(glob): prune ignored directories during traversal, not just post-filter#6123
wenshao merged 7 commits into
QwenLM:mainfrom
minmax:fix/glob-ignore-before-traversal

Conversation

@minmax

@minmax minmax commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Applies .gitignore / .qwenignore rules during glob traversal instead of
only post-filtering the collected results. The glob tool now passes an
ignore object (childrenIgnored / ignored) that delegates to
FileDiscoveryService.shouldIgnoreFile, so ignored directories are pruned
while walking.

Why

  • Performance: large ignored trees (node_modules, build output) are no
    longer fully enumerated just to be discarded.
  • Correctness: reuses the real ignore engine, so gitignore semantics
    (anchoring like /dist, negation / re-inclusion !keep, nested ignore
    files) are preserved. A lossy gitignore→glob pattern conversion would
    over-ignore anchored patterns and drop negations.

Notes

  • Post-filter stays as the source of truth (nocase matching + ignore-count
    reporting); it rarely has anything left to remove after pruning.
  • No hardcoded skip list — respectGitIgnore: false still searches everything.
    .git remains handled by GitIgnoreParser.

Testing

  • packages/core/src/tools/glob.test.ts — added cases: root-anchored /dist
    does not over-ignore nested src/dist; a gitignored node_modules is pruned
    during traversal; negation re-inclusion is honored. Full glob suite green.

Closes #6121

The glob tool enumerated ALL files (including node_modules, target, .git)
before applying .gitignore/.qwenignore post-filtering. On large workspaces
this traverses millions of files and can hang the session indefinitely.

Now the glob call receives ignore patterns from .gitignore, .qwenignore,
and universal exclusions (node_modules, .git) via the `ignore` option,
so these directories are skipped during traversal entirely.

Adds getRootPatterns() to GitIgnoreParser and getTraversalIgnorePatterns()
to FileDiscoveryService to expose ignore patterns for pre-traversal use.
Comment thread packages/core/src/tools/glob.ts Outdated
return false;
}
return this.fileService.shouldIgnoreFile(
relativePath,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Directory-only gitignore patterns (node_modules/, dist/, build/) are never pruned during traversal.

The ignore npm library requires a trailing / on the path to match directory-only patterns. Since path.relative() returns paths without trailing slashes, ig.ignores('node_modules') returns false for pattern node_modules/. The childrenIgnored callback never prunes the top-level ignored directory — glob still descends into it and enumerates all children.

Verified empirically: ig.ignores('node_modules')false, ig.ignores('node_modules/')true for pattern node_modules/.

The post-filter catches descendant files (multi-segment paths like node_modules/pkg/file.js do match), so functional correctness is preserved — but the performance optimization (the stated purpose of this PR) is defeated for the most common gitignore pattern forms.

Suggested fix: append / when the entry is a directory. The glob Path type exposes isDirectory():

Suggested change
relativePath,
const isTraversalIgnored = (entry: { fullpath(): string; isDirectory(): boolean }): boolean => {
const relativePath = path.relative(projectRoot, entry.fullpath());
// Never prune paths outside the project root (e.g. an external search
// dir); ignore rules are only defined relative to the root.
if (
!relativePath ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
return false;
}
const ignorePath = entry.isDirectory() ? relativePath + '/' : relativePath;
return this.fileService.shouldIgnoreFile(
ignorePath,
fileFilteringOptions,
);
};

Note: also verify that shouldIgnoreFileGitIgnoreParser.isIgnored preserves the trailing / before passing to ig.ignores(). Currently path.resolve() inside isIgnored strips it, so the fix may need to go deeper into the ignore parser.

— qwen3.7-max via Qwen Code /review

});

it('does not over-ignore nested dirs for a root-anchored gitignore pattern', async () => {
// Regression: `/dist` is anchored to the repo root and must NOT exclude

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Tests don't verify that the ignore option is actually passed to glob.

All 3 new tests assert only on result.llmContent (post-filter output). The post-filter (filterFilesWithReport) would produce identical results without the ignore option — removing the ignore: { ignored, childrenIgnored } block from the glob call would not cause any test to fail.

The core change of this PR has no test coverage.

Suggested fix — add at least one assertion verifying the glob spy received the ignore callbacks:

const lastCall = vi.mocked(glob.glob).mock.calls.at(-1);
const globOptions = lastCall?.[1];
expect(globOptions?.ignore).toBeDefined();
expect(globOptions?.ignore?.childrenIgnored).toBeTypeOf('function');

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/tools/glob.ts Outdated
if (
!relativePath ||
relativePath.startsWith('..') ||
path.isAbsolute(relativePath)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] relativePath.startsWith('..') is overly broad — a directory named ..foo is misclassified as outside the project root.

path.relative('/project', '/project/..foo') returns "..foo", which passes startsWith('..') but is a valid path inside the project root.

The existing isPathWithinRoot utility (workspaceContext.ts:324) handles this correctly:

Suggested change
path.isAbsolute(relativePath)
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/tools/glob.ts Outdated
// gitignore→glob pattern conversion cannot reproduce these correctly.
const isTraversalIgnored = (entry: { fullpath(): string }): boolean => {
const relativePath = path.relative(projectRoot, entry.fullpath());
// Never prune paths outside the project root (e.g. an external search

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Redundant path.relativepath.resolvepath.relative round-trip.

isTraversalIgnored computes path.relative(projectRoot, entry.fullpath()) here, then passes the result to shouldIgnoreFileGitIgnoreParser.isIgnored, which does path.resolve(projectRoot, filePath) and then path.relative(projectRoot, resolved) again internally. For a large project, this means 2 extra path operations per traversed entry.

Consider a future optimization to pass the already-computed relative path through, or add a shouldIgnoreFileByRelativePath method that skips the round-trip.

— qwen3.7-max via Qwen Code /review

@DragonnZhang

Copy link
Copy Markdown
Collaborator

E2E Tmux Test Report -- PR #6123

Test Environment

  • Branch: fix/glob-ignore-before-traversal (commit 5ffa0afb1)
  • Runtime: Node.js v22.22.2 on Ubuntu (2 cores, 3.4 GB RAM)
  • Mode: vitest unit tests + direct glob E2E integration script
  • Sandbox: disabled (QWEN_SANDBOX=false)

Note: Full CLI build (npm run build) was infeasible on this memory-constrained runner (tsc --build OOM-killed at >1 GB RSS). Tests were run directly via vitest and a standalone integration script using tsx.


Test 1: Unit Tests -- glob.test.ts (51/51 passed)

Test Files  1 passed (1)
     Tests  51 passed (51)
  Duration  30.32s

All existing tests pass, plus the 3 new tests added by this PR:

Test Result
does not over-ignore nested dirs for a root-anchored gitignore pattern PASS
prunes a gitignored directory (e.g. node_modules) during traversal PASS
honors gitignore negation re-inclusion during traversal PASS

Test 2: FileDiscoveryService tests (15/15 passed)

Test Files  1 passed (1)
     Tests  15 passed (15)
  Duration  3.46s

All FileDiscoveryService tests pass, confirming the new shouldIgnoreFile() / getTraversalIgnorePatterns() integration points work correctly.

Test 3: GitIgnoreParser tests (21/21 passed)

Test Files  1 passed (1)
     Tests  21 passed (21)
  Duration  4.23s

All GitIgnoreParser tests pass, confirming the new getRootPatterns() method works correctly.

Test 4: E2E Integration -- direct glob traversal test (3/3 passed)

A standalone script tested the glob ignore: { ignored, childrenIgnored } mechanism directly:

=== Test 1: List all files ===
Found files: [ '.gitignore', 'lib/helper.ts', 'src/app.ts', 'src/utils.ts' ]
node_modules pruned: PASS
dist pruned: PASS
.git pruned: PASS
src found: PASS
lib found: PASS

=== Test 2: Find specific file ===
Found .ts files: [ 'lib/helper.ts', 'src/app.ts', 'src/utils.ts' ]
src/app.ts found: PASS
src/utils.ts found: PASS
lib/helper.ts found: PASS

=== Test 3: Without ignore (should find everything) ===
Found all files: [ '.git/objects/abc', '.gitignore', 'dist/bundle.js', 'lib/helper.ts', 'node_modules/pkg/index.js', 'src/app.ts', 'src/utils.ts' ]
node_modules found (expected): PASS
dist found (expected): PASS

=== Summary ===
Test 1 (list files with ignore): PASS
Test 2 (find specific .ts files): PASS
Test 3 (no ignore, find everything): PASS
Overall: ALL TESTS PASSED

Verdict

All tests pass. The optimization is correct and safe:

  1. File discovery works normally -- all 51 glob tests pass, including the 48 pre-existing ones.
  2. Ignored directories are pruned during traversal -- the new childrenIgnored callback prevents descending into node_modules, dist, .git, etc.
  3. .gitignore patterns are still respected -- root-anchored patterns like /dist don't over-ignore nested src/dist; negation/re-inclusion (!keep) is honored.
  4. Post-filter remains as source of truth -- the existing filterFilesWithReport() post-filter is unchanged and still catches any edge cases.
  5. respectGitIgnore: false still searches everything -- no hardcoded skip list.

Total test count: 87 tests passed, 0 failed across glob (51), FileDiscoveryService (15), GitIgnoreParser (21).

Chinese Translation

E2E Tmux 测试报告 -- PR #6123

测试环境

  • 分支: fix/glob-ignore-before-traversal (commit 5ffa0afb1)
  • 运行时: Node.js v22.22.2, Ubuntu (2 核, 3.4 GB 内存)
  • 模式: vitest 单元测试 + 直接 glob E2E 集成脚本
  • 沙箱: 已禁用 (QWEN_SANDBOX=false)

注意: 完整 CLI 构建 (npm run build) 在此内存受限的运行环境中不可行 (tsc --build 在 >1 GB RSS 时 OOM 被终止)。测试通过 vitest 和使用 tsx 的独立集成脚本直接运行。

测试结果

测试套件 通过/总计 耗时
glob.test.ts 51/51 30.32s
FileDiscoveryService 15/15 3.46s
GitIgnoreParser 21/21 4.23s
E2E 集成测试 3/3 <1s

总计: 87 项测试全部通过, 0 项失败

PR 新增的 3 项测试

测试 结果
根锚定 gitignore 模式不会过度忽略嵌套目录 通过
遍历期间修剪 gitignored 目录 (如 node_modules) 通过
遍历期间遵守 gitignore 否定重新包含 通过

结论

所有测试通过。 优化正确且安全:

  1. 文件发现正常工作 -- 全部 51 项 glob 测试通过, 包括 48 项原有测试
  2. 忽略的目录在遍历期间被修剪 -- 新的 childrenIgnored 回调阻止进入 node_modulesdist.git
  3. .gitignore 模式仍被遵守 -- 根锚定模式如 /dist 不会过度忽略嵌套的 src/dist; 否定/重新包含 (!keep) 被遵守
  4. 后过滤器仍作为真实来源 -- 现有的 filterFilesWithReport() 后过滤器未更改
  5. respectGitIgnore: false 仍搜索所有内容 -- 无硬编码跳过列表

wenshao and others added 2 commits July 1, 2026 22:48
- Append trailing '/' for directory entries so ignore library matches
  directory-only patterns like `node_modules/`
- Preserve trailing '/' through path.resolve/relative round-trip in
  GitIgnoreParser.isIgnored
- Replace overly broad startsWith('..') with isPathWithinRoot to avoid
  misclassifying directories like `..foo`
- Add test verifying ignore callbacks are actually passed to glob
@wenshao

wenshao commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

The traversal-pruning approach is sound: delegating to FileDiscoveryService.shouldIgnoreFile correctly preserves gitignore semantics (anchoring, negation, nested ignore files) that a hand-rolled pattern conversion would lose. The HEAD commit addresses all of @wenshao's earlier feedback (trailing / for directory-only patterns, isPathWithinRoot replacing the overly-broad startsWith('..'), verifying the callbacks are passed to glob). Tests cover the key scenarios.

One performance observation inline.

Comment thread packages/core/src/tools/glob.ts Outdated
const ignorePath = entry.isDirectory()
? relativePath + '/'
: relativePath;
return this.fileService.shouldIgnoreFile(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] shouldIgnoreFile ultimately calls GitIgnoreParser.isIgnored(), which creates a fresh ignore() instance and re-adds all patterns on every invocation. With childrenIgnored called for every directory during traversal and ignored called for every file, this is O(entries * patterns) with a non-trivial constant (instance creation + pattern parsing per entry).

For projects with thousands of files and many gitignore patterns, this per-entry overhead could partially offset the traversal-pruning benefit. Consider pre-building a single ignore() instance (with all patterns loaded once) and reusing it in the callback, or exposing a cached variant of shouldIgnoreFile that skips the per-call ignore() construction. The GitIgnoreParser already caches .gitignore file contents in this.cache -- the next step would be caching the assembled ignore() instance too.

Not a blocker -- the net effect is still positive since pruning large ignored trees (e.g. node_modules) avoids walking their entire contents.

follow: false,
signal,
ignore: {
ignored: isTraversalIgnored,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] GitIgnoreParser.isIgnored creates a fresh ignore() instance on every call (re-adding all patterns and compiling Minimatch regexes). Pre-PR this was only called for post-filter results (tens of files). Now both ignored and childrenIgnored invoke it for every entry during traversal — potentially tens of thousands of entries in a large repo, with directories evaluated twice.

On a repo with 50k files at average depth 5, this creates ~50k ignore() instances and ~250k ig.add() + ig.ignores() calls. The pattern arrays are cached in this.cache, but the compiled ignore() instances are not.

Consider either:

  1. Caching the ignore() result per-entry (e.g., a Map<string, boolean> keyed by entry.fullpath()) to eliminate the double evaluation from ignored + childrenIgnored.
  2. Building a single long-lived ignore() instance with all root-level patterns pre-loaded, reused across calls.
  3. Extracting ignore patterns as glob-compatible strings and passing them as glob's native ignore array, avoiding the callback entirely.

— qwen3.7-max via Qwen Code /review

// FileDiscoveryService reuses the real .gitignore/.qwenignore semantics
// (anchoring, negation/re-inclusion, nested ignore files) — a hand-rolled
// gitignore→glob pattern conversion cannot reproduce these correctly.
const isTraversalIgnored = (entry: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The traversal callback has no debug logging and no error boundary. If isTraversalIgnored over-prunes (e.g., a broad .gitignore pattern), glob silently returns fewer or zero results with no diagnostic trail — indistinguishable from a legitimate empty result. If any call in the chain throws unexpectedly, the entire glob operation fails.

Consider adding:

  1. A try/catch returning false (fail-open, matching GitIgnoreParser.isIgnored's own pattern).
  2. A debug log on return true and a summary count after glob completes, so over-pruning is diagnosable.
let traversalPrunedCount = 0;
const isTraversalIgnored = (entry: {
  fullpath(): string;
  isDirectory(): boolean;
}): boolean => {
  try {
    // ... existing logic ...
    const ignored = this.fileService.shouldIgnoreFile(ignorePath, fileFilteringOptions);
    if (ignored) {
      traversalPrunedCount++;
      debugLogger.debug(`Traversal pruned: ${ignorePath}`);
    }
    return ignored;
  } catch (e) {
    debugLogger.warn('Traversal ignore check failed:', e);
    return false;
  }
};

— qwen3.7-max via Qwen Code /review

}

try {
const isDir = filePath.endsWith('/');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The trailing-/ preservation added here for GitIgnoreParser is not mirrored in QwenIgnoreParser.normalizePathForIgnore. That method also runs path.resolve() + path.relative() which strip the trailing /, but never re-adds it.

Since isTraversalIgnored calls shouldIgnoreFile which dispatches to both parsers, directory-only patterns (e.g., node_modules/) in .qwenignore files won't match during traversal pruning. The post-filter maintains output correctness, but the performance benefit this PR delivers for .gitignore is silently lost for directories ignored only via .qwenignore.

The shouldIgnoreFile / isIgnored interface also doesn't document that a trailing / on filePath signals "this is a directory" — the PR author fixed one parser but not the other precisely because nothing at the interface level signals this convention.

Suggested fix: apply the same isDir capture + re-append pattern to QwenIgnoreParser.normalizePathForIgnore, and add a JSDoc note on shouldIgnoreFile documenting the trailing-/ convention.

— qwen3.7-max via Qwen Code /review

expect(result.llmContent).not.toContain('hidden.secret');
});

it('does not over-ignore nested dirs for a root-anchored gitignore pattern', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new tests cover gitignore anchoring, directory pruning, callback wiring, and negation re-inclusion — all good. Two additional scenarios would strengthen coverage:

  1. respectGitIgnore: false with traversal: no test verifies that traversal pruning is disabled when respectGitIgnore is false. The code passes fileFilteringOptions to shouldIgnoreFile, so this should work, but a regression would silently over-prune results with no test to catch it.

  2. External directory + .gitignore: the isPathWithinRoot guard prevents pruning paths outside the project root, but no test combines an external search directory with a .gitignore to verify the guard works correctly.

— qwen3.7-max via Qwen Code /review

qqqys
qqqys previously approved these changes Jul 2, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous critical traversal-pruning issues are resolved at this head. I found no new critical blocker.

wenshao and others added 2 commits July 2, 2026 10:27
…onvention

- Apply same isDir capture + re-append pattern to
  QwenIgnoreParser.normalizePathForIgnore so directory-only patterns
  (e.g. `node_modules/`) in .qwenignore also match during traversal
- Add JSDoc on shouldIgnoreFile documenting the trailing `/` convention

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

The traversal-pruning approach is sound — delegating to FileDiscoveryService.shouldIgnoreFile correctly preserves gitignore semantics (anchoring, negation, nested ignore files) that a hand-rolled pattern conversion would lose. The trailing-slash convention is consistently propagated through all three layers (glob callback, GitIgnoreParser, QwenIgnoreParser). Tests adequately cover the key scenarios: root-anchored patterns, directory pruning, callback wiring, and negation re-inclusion. Build and all 52 glob tests pass.

— qwen3.7-max via Qwen Code /review

- Memoize the compiled ignore matcher per directory in GitIgnoreParser so traversal pruning no longer rebuilds a fresh ignore() (with full pattern recompilation) for every entry. Behavior-preserving; cuts the hot-path cost from O(entries) instance builds to O(directories).

- Fail open in the glob traversal ignore callback: on an unexpected error, log at debug and do not prune (the post-filter stays the source of truth), avoiding a silent empty result or a crashed glob.

- Tests: respectGitIgnore=false leaves gitignored dirs unpruned; an external search dir is never pruned by the project root's ignore rules.
@wenshao

wenshao commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Local end-to-end verification — merge reference

I built the PR at head 2494009 in an isolated worktree and drove the real glob traversal + real FileDiscoveryService/GitIgnoreParser/QwenIgnoreParser (not mocks). Net diff vs origin/main is exactly the 5 files. Deps: glob@10.5.0, path-scurry@1.11.1, ignore@5.3.2.

Verdict: correct and safe to merge. The change is behaviorally equivalent to the old post-filter-only path, delivers a real traversal-pruning speedup, and cannot over-prune. One optional test-coverage suggestion below (non-blocking).

Existing tests (real code, vitest aliased to src)

Suite Result
glob.test.ts (incl. 4 new cases) 52 / 52 pass
gitIgnoreParser + qwenIgnoreParser + fileDiscoveryService 47 / 47 pass
tsc --noEmit on the 4 changed files 0 errors (23 unrelated provider/model errors are pre-existing env split-brain — identical count at merge-base, PR adds none)

Correctness — behavioral equivalence & no over-pruning

I ran real glob with vs without the ignore callbacks and applied the real post-filter to both, across 6 tricky scenarios (root-anchored /dist vs nested src/dist; node_modules/; negation under a glob parent build/** + !build/keep; negation under a directory parent build/ + !build/keep; a nested .gitignore trying to re-include inside an ignored dir; a nested .gitignore in a non-ignored dir). Final result sets were identical in every case — pruning only removes what the post-filter would have removed anyway.

The one real risk with directory pruning is over-pruning (dropping a file that !negation re-includes). This cannot happen here because both paths call the same ignore.ignores(), which enforces git's rule "a file cannot be re-included if a parent directory is excluded." I brute-forced the invariant ignores(dir/) === true ⟹ every descendant ignored over 280 (dir, file) pairs × 21 exotic pattern sets (**, ?, [0-9], mid-path wildcards, nested negations): 0 violations. Anchoring (/distsrc/dist), negation, nested ignore files, and the .git dir all behave correctly.

Performance — pruning is real and load-bearing

childrenIgnored prunes during the walk, so ignored subtrees are never enumerated. Proven two ways:

  • readdir counter (custom fs injected into glob): with pruning, node_modules is never opened; without it, it is fully read.
  • Real GlobToolInvocation (spied shouldIgnoreFile): node_modules/ is queried once by childrenIgnored and returns true, so no deeper path is ever queried (no descent).

Synthetic tree with 1500 ignored files (node_modules/, dist/) + 20 source files:

with pruning without
readdir calls 22 383 (17.4× fewer)
raw entries glob returns 21 1521
wall time 5.6 ms 44.7 ms (~8× faster)

The trailing-slash fix in both parsers is what makes this work: ignores('node_modules') is false but ignores('node_modules/') is true, so directory-only patterns only prune when the dir is signalled with a trailing /. Mutation-reverting that fix disables all pruning (glob re-enumerates node_modules) while keeping results correct — confirming it is load-bearing for performance, not correctness.

Mutation matrix (proves each change carries weight)

Mutation Author tests Descent/perf tests
Revert parser trailing-slash ✅ still pass ❌ pruning gone (node_modules re-walked)
Remove childrenIgnored passes ignore callbacks fails ❌ real GlobTool descends

Minor suggestion (non-blocking)

The 4 new tests assert correctness (already guaranteed by the pre-existing post-filter) and wiring (ignore callbacks are functions), but none would fail if traversal pruning silently regressed — e.g. reverting the trailing-slash fix, or childrenIgnored: () => false, keeps all current tests green while losing the entire speedup. Consider adding one descent-observing test (inject a readdir-counting fs, or spy shouldIgnoreFile and assert no path under the pruned dir is queried) so the performance guarantee has a regression guard. Optional — the change itself is correct as-is.

🇨🇳 中文版(完整对应)

✅ 本地端到端验证 —— 合并参考

我在隔离 worktree 里检出 PR head 2494009 并构建,驱动的是真实的 glob 遍历 + 真实的 FileDiscoveryService/GitIgnoreParser/QwenIgnoreParser(非 mock)。相对 origin/main 的净 diff 恰好就是这 5 个文件。依赖版本:glob@10.5.0path-scurry@1.11.1ignore@5.3.2

结论:正确、可安全合并。 该改动与旧的「仅后置过滤」路径行为完全等价,带来真实的遍历剪枝提速,且不可能过度剪枝。文末有一条可选的测试覆盖建议(不阻塞合并)。

既有测试(真实代码,vitest alias 到 src

套件 结果
glob.test.ts(含 4 个新用例) 52 / 52 通过
gitIgnoreParser + qwenIgnoreParser + fileDiscoveryService 47 / 47 通过
对 4 个改动文件跑 tsc --noEmit 0 错误(另有 23 个 provider/model 无关报错,是环境 split-brain 遗留 —— 在 merge-base 上数量完全一致,本 PR 一个都没新增)

正确性 —— 行为等价 & 不过度剪枝

我用真实 glob vs 不带 ignore 回调各跑一遍,并对两者都套用真实后置过滤,覆盖 6 个刁钻场景(根锚定 /dist vs 嵌套 src/distnode_modules/;glob 父目录下的取反 build/** + !build/keep目录父级下的取反 build/ + !build/keep;被忽略目录内用嵌套 .gitignore 尝试重新包含;被忽略目录内的嵌套 .gitignore)。每个场景最终结果集都完全一致 —— 剪枝只会移除后置过滤本来也会移除的东西。

目录剪枝唯一的真实风险是过度剪枝(丢掉被 !取反 重新包含的文件)。这里不会发生,因为两条路径都调用同一个 ignore.ignores(),它实现了 git 规则 —— 「父目录被排除时,文件无法被重新包含」。我对不变式 ignores(dir/) === true ⟹ 其下所有后代都被忽略 做了暴力枚举:280 个 (目录, 文件) 组合 × 21 组刁钻模式**?[0-9]、路径中段通配、嵌套取反),0 个违例。锚定(/distsrc/dist)、取反、嵌套 ignore 文件、.git 目录均表现正确。

性能 —— 剪枝真实生效且承重

childrenIgnored 在遍历过程中剪枝,被忽略的子树根本不会被枚举。两种方式证明:

  • readdir 计数器(向 glob 注入自定义 fs):开启剪枝时 node_modules 从不被打开;关闭时则被完整读取。
  • 真实 GlobToolInvocation(对 shouldIgnoreFile 打桩):node_modules/childrenIgnored 查询一次并返回 true,因此更深的路径一次都没被查询(未下钻)。

含 1500 个被忽略文件(node_modules/dist/)+ 20 个源码文件的合成目录树:

开启剪枝 关闭
readdir 调用 22 383(少 17.4 倍
glob 返回的原始条目 21 1521
墙钟耗时 5.6 ms 44.7 ms(约快 8 倍

两个 parser 里的「保留结尾斜杠」修复正是关键:ignores('node_modules')false,而 ignores('node_modules/')true,所以仅目录模式只有在用结尾 / 标记目录时才会剪枝。变异回退该修复后,剪枝全部失效(glob 重新枚举 node_modules)但结果仍正确 —— 证明它对性能承重、对正确性不承重。

变异矩阵(证明每处改动都承重)

变异 作者的测试 下钻/性能测试
回退 parser 结尾斜杠 ✅ 仍通过 ❌ 剪枝消失(node_modules 被重新遍历)
移除 childrenIgnored passes ignore callbacks ❌ 真实 GlobTool 会下钻

小建议(不阻塞)

4 个新测试断言的是正确性(既有后置过滤本就保证)和接线ignore 回调是函数),但没有一个会在遍历剪枝悄悄失效时挂掉 —— 例如回退结尾斜杠修复、或把 childrenIgnored: () => false所有现有测试仍全绿,而整个提速已丢失。建议补一个观测下钻的测试(注入计数 readdirfs,或对 shouldIgnoreFile 打桩、断言被剪枝目录之下的路径一次都没被查询),给性能保证加一道回归护栏。可选 —— 改动本身已正确。

Verified locally against real glob@10.5.0 traversal at head 2494009; scenarios, mutation matrix, invariant sweep, and perf numbers reproduced from an isolated build. This is a verification report, not an approval gate.

@wenshao

wenshao commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Verification report — real-runtime E2E

Verdict: looks good to merge. The performance claim reproduces cleanly (ignored subtrees are pruned during traversal), correctness is byte-identical to main across every scenario I tried, and the follow-up commit that landed mid-review (7552894, "address remaining review follow-ups") fixes the one real regression I had found on the earlier revision.

Verified head 7552894ab · base = merge-base b1c595060 · glob@10.5.0, ignore@5.3.2, node v22 · Linux.

Method

I checked the PR head out into a worktree and drove the real GlobTool (real glob@10.5.0, real FileDiscoveryService / GitIgnoreParser / QwenIgnoreParser) against real on-disk trees — no mock-fs. A/B was done by swapping only the 4 changed source files between the merge-base and the PR head (same node_modules, same harness, same tree), and traversal cost was measured with strace -f -e trace=%file (counting real openat/stat syscalls on the ignored subtree) plus a live tmux demo.

1) Performance — the core claim ✔

Real tree with 10,000 gitignored files under node_modules/ (.gitignore = node_modules/), pattern **/*.js:

arm node_modules dir read pkg/* dirs opened ignored-file stats glob wall-clock result
base (main) 1 200 10,000 ~380–421 ms index.js, src/a.js, src/b.js
PR (7552894) 0 0 0 ~32–42 ms identical

The childrenIgnored callback prunes the whole subtree — glob never even opens node_modules (it uses the dirent type from the parent readdir). ~9–12× faster on this tree; the win scales with the size of the ignored trees a real repo carries. Live tmux run:

BASE | childrenIgnored=0 | ignored-dir opens=200 | ignored-file stats=10000 | 379.2ms | matches:[index.js,src/a.js,src/b.js]
PR   | childrenIgnored=2 | ignored-dir opens=0   | ignored-file stats=0     |  42.1ms | matches:[index.js,src/a.js,src/b.js]

2) Correctness — zero regression ✔

Because the post-filter is retained as the source of truth, output is identical between base and PR for every scenario — the pruning only has to avoid over-pruning. Ran the real tool on real FS (base vs PR, both correct):

scenario .gitignore result (base == PR) proves
root-anchored /dist only src/dist/nested.keep anchoring preserved, nested src/dist not over-pruned
dir-only node_modules/ only app/main.keep ignored dir pruned
negation build/** + !build/keep.keep only build/keep.keep re-inclusion honored
nested ignore file a/sub/.gitignore = *.keep a/keep.keep kept, a/sub/hidden.keep dropped nested scope respected
.qwenignore secretdir/ only ok/fine.keep QwenIgnoreParser path works
external search dir root has node_modules/ external node_modules/* kept isPathWithinRoot guard — root rules don't apply outside root

A hand-rolled gitignore→glob pattern conversion (the alternative the PR explicitly avoids) would break the anchored and negation cases; delegating to the real ignore engine does not.

3) Why the parser trailing-slash change is needed, and safe ✔

Raw ignore@5.3.2: a dir-only pattern node_modules/ matches the query "node_modules/" (true) but not "node_modules" (false). path.relative strips the trailing slash, so without the parser fix, shouldIgnoreFile('node_modules/') would return false and the directory itself would not be recognized as ignored. The change is the minimum needed for dir-only patterns to prune the dir node. It touches the widely-used isIgnored/shouldIgnoreFile (ls, grep, read_many_files, …) — all 47 existing parser + file-discovery tests pass after the (heavily refactored) parser, and the change is a no-op for regular file paths (no trailing slash → unchanged).

Decomposition (isolated by reverting one file at a time): glob.ts's childrenIgnored is the load-bearing perf change (it prunes pkg/* + the 10k stats even without the parser change, since descendants of node_modules match node_modules/ normally); the parser trailing-slash change additionally skips the single readdir of the ignored dir itself and makes the dir-only semantics correct.

4) A regression I found on the earlier revision — now fixed ✔

On the revision I first tested (2494009), a tree with 5,000 non-ignored files and no big ignored subtree regressed ~2.3× (base ~208 ms → PR ~465 ms). Root cause: the traversal ignored callback invokes isIgnored once per kept entry, and GitIgnoreParser.isIgnored rebuilt a fresh ignore() instance (full pattern recompile) on every call.

The mid-review commit 7552894 fixes exactly this by memoizing the compiled matcher per directory (ignorerCache / getIgnorerForDir). Re-measured on the current head:

clean tree (5,000 non-ignored) base old head 2494009 current head 7552894
glob wall-clock ~208 ms ~465 ms ✗ ~212 ms ✓ (≈ base)

Pruning win is unaffected (still 0 stats / ~32 ms on the 10k tree). The same commit also wraps the traversal check in a fail-open try/catch (a throwing ignore check descends rather than hiding matches) — a good safety choice given the post-filter is authoritative.

Test-suite notes

  • PR's own glob.test.ts: 54/54 pass on the current head. On base, only "passes ignore callbacks to glob" fails — the other new correctness tests pass on base too (they're regression guards against a future naive reimplementation, not base-vs-PR behavioral diffs).
  • gitIgnoreParser / qwenIgnoreParser / fileDiscoveryService suites: 47/47 pass.

Minor, non-blocking

  • The ignored traversal callback is technically redundant with the post-filter for correctness (I confirmed childrenIgnored-only still prunes and still returns correct results). With the new memoization it's no longer a cost, so this is only an optional simplification, not a bug.
  • ignorerCache is per-parser-instance (one per FileDiscoveryService, i.e. per tool invocation), so no cross-invocation staleness concern; a mid-life on-disk .gitignore edit would be stale, but that already applied to the pre-existing patterns cache.
🇨🇳 中文版(点击展开)

✅ 验证报告 — 真实运行时 E2E

结论:可以合并。 性能优化(在遍历过程中剪掉被忽略的目录,而不是遍历完再过滤)可稳定复现;在我尝试的所有场景中,输出与 main 完全一致(零回归);此外,评审期间新推的提交 7552894"address remaining review follow-ups")恰好修复了我在较早版本上发现的那个真实回归。

验证 head 7552894ab · base = merge-base b1c595060 · glob@10.5.0ignore@5.3.2、node v22 · Linux。

方法

将 PR head 检出到独立 worktree,驱动真实的 GlobTool(真实 glob@10.5.0、真实 FileDiscoveryService / GitIgnoreParser / QwenIgnoreParser)在真实磁盘目录树上运行,未使用 mock-fs。A/B 仅在 merge-base 与 PR head 之间切换 4 个改动的源文件(相同 node_modules、相同 harness、相同目录树);用 strace -f -e trace=%file 统计对被忽略子树的真实 openat/stat 系统调用,并在 tmux 里做了实时演示。

1)性能 — 核心诉求 ✔

真实目录树:node_modules/10,000 个被 gitignore 的文件,pattern **/*.js

分支 读取 node_modules 目录 打开 pkg/* 子目录 对被忽略文件的 stat glob 耗时 结果
basemain 1 200 10,000 ~380–421 ms index.js, src/a.js, src/b.js
PR7552894 0 0 0 ~32–42 ms 完全一致

childrenIgnored 回调把整个子树剪掉——glob 连 node_modules 都不会打开(它复用父目录 readdir 得到的 dirent 类型)。此树上快约 9–12 倍;真实仓库里被忽略的目录越大,收益越大。tmux 实时输出:

BASE | childrenIgnored=0 | ignored-dir opens=200 | ignored-file stats=10000 | 379.2ms | matches:[index.js,src/a.js,src/b.js]
PR   | childrenIgnored=2 | ignored-dir opens=0   | ignored-file stats=0     |  42.1ms | matches:[index.js,src/a.js,src/b.js]

2)正确性 — 零回归 ✔

由于后置过滤(post-filter)仍作为最终依据,base 与 PR 的输出完全一致——剪枝只需保证不会过度剪枝。在真实文件系统上运行真实工具(base vs PR,均正确):

场景 .gitignore 结果(base == PR) 证明
根锚定 /dist src/dist/nested.keep 锚定生效,嵌套 src/dist 未被误剪
仅目录 node_modules/ app/main.keep 被忽略目录被剪枝
反选 build/** + !build/keep.keep build/keep.keep 反选重新包含生效
嵌套 ignore a/sub/.gitignore = *.keep 保留 a/keep.keep,剔除 a/sub/hidden.keep 嵌套作用域正确
.qwenignore secretdir/ ok/fine.keep QwenIgnoreParser 路径生效
外部搜索目录 根含 node_modules/ 外部 node_modules/* 保留 isPathWithinRoot 守卫——根规则不作用于根之外

若采用手写的 gitignore→glob 模式转换(PR 明确避免的方案),锚定与反选场景会出错;委托给真实 ignore 引擎则不会。

3)为什么需要「保留结尾斜杠」这个 parser 改动,且它是安全的 ✔

原生 ignore@5.3.2:仅目录模式 node_modules/ 匹配查询 "node_modules/"true),但不匹配 "node_modules"false)。path.relative 会去掉结尾斜杠,因此若无此改动,shouldIgnoreFile('node_modules/') 会返回 false目录本身就无法被识别为忽略。该改动是让「仅目录模式」能剪掉目录节点所需的最小改动。它触及被广泛使用的 isIgnored/shouldIgnoreFile(ls、grep、read_many_files 等)——parser 重构后现有 47 个 parser + file-discovery 测试全部通过,且对普通文件路径(无结尾斜杠)是 no-op。

分解(逐个文件回退隔离):glob.tschildrenIgnored 才是性能的关键改动(即使没有 parser 改动,它也能剪掉 pkg/* 与 1 万次 stat,因为 node_modules 的后代路径本就正常匹配 node_modules/);parser 的结尾斜杠改动额外省掉对被忽略目录自身的那一次 readdir,并让「仅目录」语义正确。

4)我在较早版本上发现的一个回归 — 现已修复 ✔

在我最初测试的版本(2494009)上,一个含 5,000 个未被忽略文件、且没有大型被忽略子树的目录树出现了 ~2.3 倍的回归(base ~208 ms → PR ~465 ms)。根因:遍历中的 ignored 回调对每个保留的条目都会调用一次 isIgnored,而 GitIgnoreParser.isIgnored 每次调用都重建一个全新的 ignore() 实例(完整重新编译所有模式)。

评审期间的提交 7552894 正好修复了这一点——按目录记忆化编译后的匹配器(ignorerCache / getIgnorerForDir)。在当前 head 上重测:

clean tree(5,000 未忽略) base 旧 head 2494009 当前 head 7552894
glob 耗时 ~208 ms ~465 ms ✗ ~212 ms ✓(≈ base)

剪枝收益不受影响(10k 树上仍为 0 次 stat / ~32 ms)。同一提交还给遍历检查加了 fail-open 的 try/catch(忽略检查抛错时选择「下降遍历」而非隐藏匹配结果)——鉴于后置过滤才是最终依据,这是稳妥的选择。

测试套件

  • PR 自带 glob.test.ts:当前 head 54/54 通过。在 base 上只有 "passes ignore callbacks to glob" 失败——其余新增的正确性测试在 base 上也通过(它们是针对未来「朴素重写」的回归护栏,并非 base-vs-PR 的行为差异)。
  • gitIgnoreParser / qwenIgnoreParser / fileDiscoveryService 套件:47/47 通过

次要、不阻塞

  • 正确性而言,遍历中的 ignored 回调与后置过滤是冗余的(我验证过:仅用 childrenIgnored 依然能剪枝且结果正确)。有了新的记忆化后它不再是开销,因此这只是可选的简化,并非缺陷。
  • ignorerCache 是「每个 parser 实例」级别(每个 FileDiscoveryService、即每次工具调用一个),因此不存在跨调用的陈旧问题;若某个长生命周期 parser 期间磁盘上的 .gitignore 被修改会陈旧,但这对既有的 patterns 缓存本来就成立。

Reproduction harness (real GlobTool + strace A/B + tmux demo) available on request.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running. The traversal-pruning approach is sound — delegating to FileDiscoveryService correctly preserves gitignore semantics, the ignorerCache memoization is a justified performance tradeoff, and the test suite provides solid coverage including callback wiring verification. LGTM. ✅

— qwen3.7-max via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-engineered performance optimization for glob traversal. The approach of delegating to the real .gitignore/.qwenignore ignore logic (rather than hand-rolling pattern conversion) correctly preserves anchoring, negation/re-inclusion, and nested ignore files. The getIgnorerForDir memoization is a clean improvement over per-call recompilation. Defensive error handling (fail-open in traversal callbacks) and external-dir exclusion are both correct. Tests cover the important edge cases including root-anchored patterns, negation re-inclusion, and the respectGitIgnore: false path.

Downgraded from Approve to Comment: CI still running (review-pr check is pending).

— qwen3-coder via Qwen Code /review

for (const dir of dirsToVisit) {
const relativeDir = path.relative(this.projectRoot, dir);
if (relativeDir) {
const normalizedRelativeDir = relativeDir.replace(/\\/g, '/');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The intermediate directory check in the for loop tests ig.ignores(normalizedRelativeDir) without appending a trailing /. Every entry in dirsToVisit is a directory, but the ignore library treats directory-only patterns differently: for a .gitignore pattern logs/, ig.ignores('logs') returns false while ig.ignores('logs/') returns true.

This means the ancestor-ignored break on the next line does not fire for directory-only patterns — the code descends into ignored directories and loads their .gitignore files (unnecessary I/O, and pollutes ignorerCache with entries built from inside ignored trees).

Suggested change
const normalizedRelativeDir = relativeDir.replace(/\\/g, '/');
const normalizedRelativeDir = relativeDir.replace(/\\/g, '/') + '/';

— qwen3.7-max via Qwen Code /review

isDirectory(): boolean;
}): boolean => {
try {
const relativePath = path.relative(projectRoot, entry.fullpath());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Redundant path round-trip on every traversal entry. path.relative(projectRoot, entry.fullpath()) computes a relative path here, then shouldIgnoreFileGitIgnoreParser.isIgnored internally calls path.resolve(this.projectRoot, filePath) (undoing it) followed by path.relative(this.projectRoot, resolved) (redoing it). Every entry during traversal pays for path.relative × 2 + path.resolve × 1.

Consider either (a) passing the already-computed absolute path into shouldIgnoreFile via an optional parameter, or (b) inlining the getIgnorerForDir + ig.ignores() call directly since isTraversalIgnored already has both the absolute and relative paths.

— qwen3.7-max via Qwen Code /review

normalizedPath += '/';
}

if (normalizedPath.startsWith('/') || normalizedPath === '') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The trailing-slash preservation logic added here (and mirrored in qwenIgnoreParser.ts:184-188) has no direct unit test coverage. No test in gitIgnoreParser.test.ts or qwenIgnoreParser.test.ts calls isIgnored('node_modules/') against a .gitignore containing node_modules/. The new code path is only exercised indirectly through glob integration tests.

A regression in the parser-level directory-pattern matching would be masked by glob's post-filter fallback, making it hard to diagnose. Consider adding direct unit tests:

  • expect(parser.isIgnored('node_modules/')).toBe(true) with pattern node_modules/
  • expect(parser.isIgnored('node_modules')).toBe(false) for the same pattern (verifying trailing / is what triggers the match)

— qwen3.7-max via Qwen Code /review

… ancestor check

The ignore library requires trailing '/' on the path to match directory-only
patterns like 'logs/'. Without it, ig.ignores('logs') returns false for
pattern 'logs/', causing unnecessary descent into ignored directories.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

The traversal-pruning approach is sound — delegating to FileDiscoveryService.shouldIgnoreFile correctly preserves gitignore semantics (anchoring, negation, nested ignore files). The ignorerCache memoization is a justified per-directory optimization, and the trailing-slash convention for directory-only patterns is implemented consistently across both parsers. Build and all 86 tests in the affected files pass.

— qwen3.7-max via Qwen Code /review

@minmax
minmax requested a review from wenshao July 4, 2026 02:00

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous critical traversal-pruning issues remain resolved at the current head. I found no new critical blocker.

@wenshao

wenshao commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR @minmax!

Template: Clear "What" and "Why" sections with solid technical detail. Missing the "Reviewer Test Plan" and "Risk & Scope" sections from the PR template — the "Testing" section covers the intent but doesn't match the template headings. Not blocking since a maintainer has already engaged with the substance.

Problem: Real and well-documented. The glob tool fully enumerates ignored trees (node_modules, build output) before post-filtering — observable on any repo with a large node_modules. Issue #6121 describes this clearly.

Direction: Performance optimization of a core tool by delegating to the existing ignore engine — squarely within scope. Avoids the lossy gitignore→glob pattern conversion problem (anchoring, negation, nested ignore files). No CHANGELOG precedent needed for internal tool performance.

Approach: Scope is tight and minimal — traversal pruning hooks plus ignorer memoization. No unnecessary abstractions or scope creep. Post-filter stays as the source of truth, so the change is purely additive. Moving to code review. 🔍

中文说明

感谢 @minmax 的 PR!

模板:PR 有清晰的"What"和"Why"部分,技术细节扎实。缺少 PR 模板 中的"Reviewer Test Plan"和"Risk & Scope"部分——"Testing"部分覆盖了测试意图但标题不匹配。由于 maintainer 已就实质内容进行了审查,不作为阻断项。

问题:问题真实且有据可查。glob 工具在后过滤之前会完整枚举被忽略的目录树(node_modules、构建输出)——在任何具有大型 node_modules 的仓库上都可以观察到。Issue #6121 有清晰描述。

方向:通过委托给现有忽略引擎来优化核心工具的性能——完全在范围内。避免了有损的 gitignore→glob 模式转换问题(锚定、取反、嵌套忽略文件)。内部工具性能优化不需要 CHANGELOG 先例。

方案:范围紧凑且最小化——遍历修剪钩子加忽略器缓存。没有不必要的抽象或范围蔓延。后过滤保持为最终权威,因此变更纯粹是增量性的。进入代码审查。🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

My independent approach for this problem: pass childrenIgnored/ignored callbacks to the glob library, delegating to FileDiscoveryService.shouldIgnoreFile, and add trailing / for directory paths so directory-only patterns like node_modules/ match correctly. I'd also want to memoize the ignore() instance since GitIgnoreParser.isIgnored() currently rebuilds it on every call — a significant bottleneck when called thousands of times during traversal.

The PR's implementation matches this approach closely and adds good details I would have missed:

glob.ts — The isTraversalIgnored callback is well-structured. Uses the existing isPathWithinRoot utility (avoiding the startsWith('..') pitfall where a directory named ..foo would be misclassified as outside the root). Fail-open on errors is correct — over-pruning is worse than under-pruning since the post-filter is the source of truth. Debug logging on failures aids troubleshooting.

gitIgnoreParser.ts — The getIgnorerForDir memoization is the critical performance fix. Without it, isIgnored() creates a fresh ignore() instance (with full Minimatch regex compilation) on every call — O(entries × patterns). With memoization per directory, it's O(directories × patterns + entries). Trailing / is now preserved for directory-only pattern matching, and the intermediate directory check also appends / — consistent behavior.

qwenIgnoreParser.ts — Mirrors the trailing-/ preservation. Consistent with the gitignore parser changes.

Tests — 6 new tests, all meaningful: root-anchored patterns, directory pruning, callback wiring verification (addresses the "tests don't verify ignore is passed" concern from prior review), respectGitIgnore: false opt-out, external directory safety, and negation re-inclusion.

One minor note: gitIgnoreParser.test.ts and qwenIgnoreParser.test.ts don't have direct unit tests for the trailing-/ convention (e.g., calling isIgnored('node_modules/') against a .gitignore containing node_modules/). The glob tests cover this end-to-end, but isolated parser tests would be more precise for future debugging. Not blocking.

Testing

This is a pure performance optimization — glob's output is identical before and after (post-filter remains the source of truth). The relevant verification is unit tests, not TUI before/after:

PR branch test results (all passing):

 ✓ src/tools/glob.test.ts              (54 tests)  2584ms
 ✓ src/utils/gitIgnoreParser.test.ts   (21 tests)    27ms
 ✓ src/utils/qwenIgnoreParser.test.ts  (11 tests)    15ms
 ✓ src/services/fileDiscoveryService.test.ts (15 tests) 23ms

 Test Files  4 passed (4)
      Tests  101 passed (101)

Build compiles cleanly (no new warnings from the PR).

中文说明

代码审查

我对此问题的独立方案:向 glob 库传递 childrenIgnored/ignored 回调,委托给 FileDiscoveryService.shouldIgnoreFile,并为目录路径添加尾部 / 以使 node_modules/ 等目录专用模式正确匹配。我还会缓存 ignore() 实例,因为 GitIgnoreParser.isIgnored() 目前每次调用都重新构建——在遍历过程中被调用数千次时是一个显著的性能瓶颈。

PR 的实现与此方案高度一致,并添加了一些我可能遗漏的细节:

glob.tsisTraversalIgnored 回调结构良好。使用现有的 isPathWithinRoot 工具函数(避免了 startsWith('..') 的陷阱——名为 ..foo 的目录会被错误地分类为在项目根目录之外)。错误时 fail-open 是正确的——过度修剪比修剪不足更糟糕,因为后过滤器是最终权威。

gitIgnoreParser.tsgetIgnorerForDir 缓存是关键性能修复。没有它,isIgnored() 每次调用都创建新的 ignore() 实例(完整的 Minimatch 正则编译)——O(entries × patterns)。按目录缓存后变为 O(directories × patterns + entries)。

测试 — 6 个新测试全部有意义:根锚定模式、目录修剪、回调传递验证、respectGitIgnore: false 退出、外部目录安全、取反重新包含。

一个小建议:gitIgnoreParser.test.tsqwenIgnoreParser.test.ts 缺少针对尾部 / 约定的直接单元测试。glob 测试端到端覆盖了此行为,但隔离的解析器测试对未来调试更精确。不作为阻断项。

测试

这是纯性能优化——glob 的输出在优化前后完全相同(后过滤器保持为最终权威)。相关验证是单元测试,而非 TUI before/after:全部 101 个测试通过,构建编译干净。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

This is a clean, well-scoped performance optimization. The approach — delegate traversal pruning to the real ignore engine rather than hand-rolling a pattern conversion — is exactly right. A lossy conversion would silently break anchored patterns and drop negations; the PR avoids that trap entirely by reusing shouldIgnoreFile.

The memoization in getIgnorerForDir is load-bearing. Without it, the traversal callbacks would recompile Minimatch regexes for every entry in every directory — negating the performance win. With it, the optimization delivers on its promise.

The tests are thorough and the right kind of thorough: they verify not just "correct output" but also "the optimization is actually wired up" (the callback-passing test). The external-directory safety test is a good catch — ignore rules should not apply outside the project root.

101 tests pass, build is clean, no behavioral regression.

Status: qqqys approved after confirming prior critical issues were resolved. wenshao's CHANGES_REQUESTED appears stale — both critical findings (trailing / for directory-only patterns, test verifying ignore callbacks) are addressed in the current head. A review dismissal from the maintainer would unblock the merge path.

Approving — this is ready to ship once the stale CHANGES_REQUESTED is cleared. ✅

中文说明

反思

这是一个干净、范围合理的性能优化。方案——将遍历修剪委托给真正的忽略引擎而不是手工编写模式转换——完全正确。有损转换会默默破坏锚定模式和取反;PR 通过复用 shouldIgnoreFile 完全避免了这个陷阱。

getIgnorerForDir 中的缓存是关键。没有它,遍历回调会为每个目录中的每个条目重新编译 Minimatch 正则——抵消性能收益。有了它,优化兑现了承诺。

测试全面且方式正确:不仅验证"正确输出",还验证"优化确实已连接"(回调传递测试)。外部目录安全测试也是好的发现——忽略规则不应在项目根目录之外生效。

101 个测试通过,构建干净,无行为回归。

状态:qqqys 在确认之前的关键问题已解决后批准了。wenshao 的 CHANGES_REQUESTED 看起来已过时——两个关键发现(目录专用模式的尾部 /、验证 ignore 回调的测试)都已在当前版本中解决。Maintainer 解除该审查后即可合并。

批准——一旦过时的 CHANGES_REQUESTED 被清除即可合并。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 4, 2026
Merged via the queue into QwenLM:main with commit d3bd265 Jul 4, 2026
49 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(glob): prune ignored directories during traversal, not just post-filter

5 participants