[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)

Status Fixed / completed
Maintainer reply None cached
Activity 1 comment · opened Sep 17, 2026 · closed Sep 24, 2026

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.fsmonitor accepts 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=, plus GIT_CONFIG_* scrubbing). But it is not the cause here. With a healthy git fsmonitor--daemon already running for 23h, fsmonitor=false cost 16.8s and fsmonitor=true cost 19.5s — no improvement. ls-files --others does not benefit from it.
  • core.untrackedCache. Already true in this repo, along with feature.manyFiles=true. No effect on this command.
  • git status --porcelain=v2 -uall instead (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:

  1. 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.
  2. 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.
  3. Make it event-driven. Use a filesystem watcher and rescan on change, rather than polling. The app already depends on change detection elsewhere.
  4. 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.

View original on GitHub ↗

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