[BUG] startup performs multiple full working-tree walks that bypass .gitignore

Status Open
Reported on v2.1.232
Maintainer reply None cached
Activity 1 comment · opened Aug 14, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

In a monorepo whose working tree holds ~340k files / ~40k directories — >95% of them gitignored dependency trees (node_modules, ~40 Python .venvs, Rust target/) — every claude -p "hey" invocation spends ~9.6 s of silent CPU time before the first API request is dispatched. The same invocation in an empty directory spends ~20 ms in that phase.

strace -f -c -e trace=getdents64 over the whole invocation counts 2.1 million getdents64 calls (~3.6 s in the syscall alone, the rest is userspace processing). ~40k directories would need roughly 100k calls for a single enumeration, so the tree is effectively walked ~20 times.

Narrowing it down:

  1. The walks do not prune gitignored directories. Deleting ignored trees shrinks the delay linearly while the checked-in file count (~6k) is unchanged:

| files in working tree | pre-dispatch gap | total claude -p wall | CPU (user+sys) |
|---|---|---|---|
| ~340k | 9.6 s | ~14.4 s | ~11 s |
| ~189k (node_modules deleted) | 2.9 s | 7.7 s | ~4 s |
| ~14k (venvs also deleted) | <0.3 s | 4.8 s | 1.5 s |

  1. Per-process attribution: re-adding a single small .venv (3,736 files) as bait and tracing openat shows every path inside it opened by one thread of a helper process, each directory exactly 6 times — six complete, unpruned walks by a single subsystem. That process's startup profile: loads its own libc (fresh exec), probes cgroup cpu/memory limits, reads all settings layers in order (/etc/claude-code/managed-settings.json~/.claude/settings.json.claude/settings.json.claude/settings.local.json) plus ~/.claude/.credentials.json, then walks the tree from the project root with O_DIRECTORY opens, reading .gitignore files as it goes but descending into ignored directories anyway.
  1. Additional walkers (threads of the main node process) traverse the tracked tree concurrently, also reading .gitignore per directory and ~/.config/git/ignore.

The settings in this setup include several ** glob patterns in permissions.deny (e.g. Read(**/.env), Read(**/.env.*)) and sandbox.filesystem.denyWrite/allowWrite (e.g. **/.env*, **/*.env*), plus SessionStart/PreToolUse/PostToolUse/Stop hooks. The sandbox spec observably resolves those globs into concrete --ro-bind /dev/null <match> entries for every matching file in the tree, including matches inside gitignored directories — which a security resolver arguably must find (a stray .env inside a venv still needs masking), but the current implementation appears to pay a full unpruned tree walk per pattern (or pattern group), ~20 walks total, on every startup, before the first API request.

What Should Happen?

  • Enumerate the tree once per startup and share the result across all consumers (glob resolution, tracked-tree walkers), instead of ~20 independent walks.
  • Do the work lazily/asynchronously so it doesn't sit between prompt submission and API dispatch (headless -p pays the full cost even when no tool is ever invoked).
  • And/or provide a documented way to exclude directories from startup scanning (per the docs and #620, none exists today: claudeMdExcludes only affects memory files, permissions.deny only blocks runtime reads).

Even keeping security semantics (globs must see ignored dirs), one walk instead of twenty would cut the observed overhead ~95%.

Error Messages/Logs

No errors. With `--debug-file`, the cost is a single unlogged gap between context-attachment assembly and dispatch (empty dir: ~20 ms; monorepo: 8.9–9.6 s across 3 runs):


07:14:25.486 [DEBUG] LSP Diagnostics: getLSPDiagnosticAttachments called
07:14:25.487 [DEBUG] Sending 64 skills via attachment (initial)
07:14:25.491 [DEBUG] Fast mode unavailable: Fast mode is not available in the Agent SDK
07:14:34.338 [DEBUG] Dynamic tool loading: 0/70 deferred tools included   <-- 8.85 s silent gap
07:14:34.341 [DEBUG] [API:timing] dispatching to firstParty model=claude-fable-5


strace summary for the full invocation:


% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------
100.00    3.615573           1   2099542           getdents64


Walker sample (openat trace; one thread, O_DIRECTORY opens, descending into a gitignored `.venv`; every directory below it opened exactly 6× over the run):


371  openat(AT_FDCWD, "<project>/.claude/settings.local.json", O_RDONLY|O_LARGEFILE) = 10
371  openat(AT_FDCWD, "<project>", O_RDONLY|O_LARGEFILE|O_PATH) = 8
...
371  openat(AT_FDCWD, "<project>/<pkg>/.venv/lib/python3.14/site-packages/yaml", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 9

Steps to Reproduce

  1. Use a working directory that is a git repo with a few hundred thousand gitignored files (e.g. several node_modules and Python .venv trees), configure project settings containing ** glob patterns in permissions.deny and sandbox.filesystem.denyWrite, plus a SessionStart hook:

``json
{
"permissions": { "deny": ["Read(**/.env)", "Read(**/.env.*)"] },
"sandbox": { "filesystem": { "denyWrite": ["**/.env*", "**/*.env*"] } },
"hooks": { "SessionStart": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo hi", "timeout": 5 }] }] }
}
``

  1. Run time claude -p "hey" --debug-file /tmp/claude-debug.log.
  2. Compare against the same command in an empty directory; observe the multi-second gap before dispatching to firstParty in the debug log, and strace -f -c -e trace=getdents64 counts in the millions.

Note on minimal repro: a synthetic repo (280k gitignored files generated by script, same settings as above) did not reproduce the walk when tested from inside a Claude Code bash sandbox — but in that environment the SessionStart hook aborts early with EROFS on ~/.claude/session-env, which likely also skips the sandbox/glob-resolution path suspected as the trigger. The real-repo numbers above are from normal (unsandboxed) shells. The trigger seems tied to the sandbox/hook configuration being active, not to tree shape: gitignore edge cases (nested .gitignore files, **/dir/ patterns, ! negations) were each tested synthetically and are pruned correctly by the tracked-tree walkers.

Claude Model

Other

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.232 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Other

Additional Information

Environment: Linux 6.17 x86_64, ext4. Timings reproduced across 5+ runs; the scaling table above was produced by deleting/restoring the ignored trees with no other changes.

Ruled Out

  • Hooks themselves — the SessionStart hook completes in ~140 ms per the debug log.
  • #10979 (startup ripgrep)rg was exec'd twice and accounts for <5k of the 2.1M directory reads; per-PID attribution puts the bulk in the main process's threads and the settings-reading helper process.
  • MCP servers / network — MCP connects run concurrently and finish earlier; API first-byte is ~1.5 s and unaffected.
  • gitgit status --porcelain in this repo takes 25 ms.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗