[BUG] Desktop app re-runs `git ls-files --others :/` ~2.2×/sec without single-flighting, burning ~3 cores to find 3 untracked files (macOS, large monorepo)
Summary
On macOS, the desktop app's diff/changes pane repeatedly spawns:
git ... -c core.fsmonitor=false ls-files --others --exclude-standard --full-name :/
There is no single-flight guard: a new scan is started every ~0.45s regardless of whether the previous one has finished. On a large monorepo each scan needs ~1.5s of CPU and ~17s of wall time, so they accumulate. I measured 32–51 concurrent git processes and a load average of 265 on a 10-core M2 Pro, with the app otherwise idle — no prompt running, no build, no user input.
The scan returns 3 untracked files. It is re-run roughly 190,000 times/day to rediscover the same 3 paths.
Environment
| | |
|---|---|
| Claude desktop app | 2.110.1 |
| OS | macOS 15.5, arm64 |
| Hardware | Mac14,9 (M2 Pro, 10 cores) |
| git | 2.39.5 (Apple Git-154) |
| Repo | monorepo, 70 submodules, 26 GB .git |
Measurements
All taken with the app idle (no active prompt, no build running).
Spawn rate — new PIDs matching the scan over a 10s window:
new ls-files PIDs in 10s: 22 => 2.20/sec
concurrent now: 32
Pile-up — age distribution of live scans. One to three processes alive at every second from 0 to 17, i.e. they are started far faster than they drain:
1 00:00 2 00:05 2 00:10 3 00:15
3 00:01 3 00:06 2 00:11 3 00:16
2 00:02 2 00:07 3 00:12 1 00:17
2 00:03 3 00:08 3 00:13
2 00:04 3 00:09 2 00:14
Cost per scan — three consecutive runs of the exact command the app runs:
run1 user 0.28 sys 1.33
run2 user 0.28 sys 1.23
run3 user 0.28 sys 1.16
~1.5s CPU each, dominated by sys (directory traversal syscalls).
Output size:
$ git ls-files --others --exclude-standard --full-name :/ | wc -l
3
Resulting system state:
load average: 265.05, 260.83, 258.99
git process count: 58
total %CPU across all processes: 529.8
The arithmetic
2.2 scans/sec × ~1.5s CPU per scan ≈ 3.3 cores consumed continuously, on a 10-core machine, to discover 3 files. This is independent of contention — it is CPU time, not wall time.
The wall-clock inflation to ~17s per scan is then self-amplifying: the scans contend with each other for filesystem I/O, which makes each slower, which increases how many are alive at once.
Functional consequence
The call site passes a 30s timeout and returns [] on timeout:
try {
i = await e.run(["ls-files","--others","--exclude-standard","--full-name",":/"], t, 3e4)
} catch { return [] }
At 17s and climbing, this is approaching its own timeout. Once crossed, the pane silently shows no untracked files while still burning the same CPU — the feature degrades to nothing but keeps the full cost.
Call site
app.asar offset 2063953, duplicated at 3994157 (minified; names are mangled):
async function Gi(e, t, n, r) {
let i;
try {
i = await e.run(["ls-files","--others","--exclude-standard","--full-name",":/"], t, 3e4)
} catch { return [] }
let a = i.split("\n").filter(Boolean);
if (a.length === 0) return [];
let o = a.slice(0, Si); // file-count cap
let s = { bytes: r }; // byte budget
let c = (await Ni(o, e.workingFileReadConcurrency, t => $i(e, n, t, s))) ...
The preceding function is a unified-diff parser, so this appears to feed the diff/changes view. The need for ls-files --others is understood: git diff cannot represent untracked files, so they must be listed and read separately to synthesize an added-lines diff.
Two observations about the caps: slice(0, Si) and the byte budget bound how many files are read, but git ls-files has already walked the whole tree by then — the walk itself is unbounded. And :/ forces the scan from the repo root regardless of the user's working directory, so it always covers all 70 submodule paths.
Things I ruled out
To preempt the obvious suggestions — none of these help, the walk cost is inherent:
core.fsmonitor. The app passes-c core.fsmonitor=false. I understand why:core.fsmonitoraccepts a path to an executable hook, so disabling it belongs with the other ACE guards the app sets (core.hooksPath=/dev/null,core.askPass=,protocol.ext.allow=never,core.sshCommand=false,safe.bareRepository=explicit,credential.helper=, plusGIT_CONFIG_*scrubbing). But it is not the cause here. With a healthygit fsmonitor--daemonalready running for 23h,fsmonitor=falsecost 16.8s andfsmonitor=truecost 19.5s — no improvement.ls-files --othersdoes not benefit from it.core.untrackedCache. Alreadytruein this repo, along withfeature.manyFiles=true. No effect on this command.git status --porcelain=v2 -uallinstead (which does use the untracked cache):user 0.29 / sys 1.23— identical cost. Not a fix.
So the problem is not which git command is used or how it is configured. It is that a ~1.5s-CPU operation is issued 2.2 times per second with no coalescing.
Suggested fixes
In rough order of value:
- Single-flight the scan. If one is in flight, don't start another. This alone would cut ~32 concurrent processes to 1 and is the smallest possible change.
- Adapt the interval to measured cost. After a scan takes N ms, wait at least some multiple of N before the next. A scan that costs 1.5s of CPU should not be repeated every 0.45s.
- Make it event-driven. Use a filesystem watcher and rescan on change, rather than polling. The app already depends on change detection elsewhere.
- Gate on visibility. Only refresh when the diff/changes pane is actually open and focused.
Related
#94478 reports the same underlying behavior on Windows (~15–20 git spawns/sec, ~2M/day) and attributes it to a ~60ms poll interval. This report adds the macOS case, the per-scan CPU cost, the pile-up mechanism (scan duration far exceeding the respawn interval), the approaching-timeout failure mode, and evidence that git-side configuration cannot mitigate it.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗