[BUG] startup performs multiple full working-tree walks that bypass .gitignore
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:
- 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 |
- Per-process attribution: re-adding a single small
.venv(3,736 files) as bait and tracingopenatshows 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 withO_DIRECTORYopens, reading.gitignorefiles as it goes but descending into ignored directories anyway.
- Additional walkers (threads of the main node process) traverse the tracked tree concurrently, also reading
.gitignoreper 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
-ppays 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:
claudeMdExcludesonly affects memory files,permissions.denyonly 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
- Use a working directory that is a git repo with a few hundred thousand gitignored files (e.g. several
node_modulesand Python.venvtrees), configure project settings containing**glob patterns inpermissions.denyandsandbox.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 }] }] }
}
- Run
time claude -p "hey" --debug-file /tmp/claude-debug.log. - Compare against the same command in an empty directory; observe the multi-second gap before
dispatching to firstPartyin the debug log, andstrace -f -c -e trace=getdents64counts 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) —
rgwas 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.
- git —
git status --porcelainin this repo takes 25 ms.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗