Skip to content

fix: stop a long-running app spawning git around the clock - #81

Merged
rocketraman merged 2 commits into
masterfrom
claude/gitvantage-lag-apple-silicon-97ec14
Nov 3, 2026
Merged

rocketraman merged 2 commits into
masterfrom
claude/gitvantage-lag-apple-silicon-97ec14

Conversation

@rocketraman

@rocketraman rocketraman commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

Why

A macOS user (Apple Silicon, macOS 26.1) reported the app getting laggy after running for about a day. Their evidence:

  • A 15s sample of the process: about 10 IO threads were in fork() or reading git output in nearly every sample. The main thread spent 10–15% of the window blocked on locks that fork() holds: malloc zones and the Objective-C weak table (the "AppKit lock" in the report). A FileSystemWatcher thread was polling $TMPDIR.
  • A 25-minute perf log (1.5.0): about 96,400 git runs, which is ~65 a second and ~115 per repo scan (44 repos, 20 worktrees). It also recorded 20 UI freezes of 0.5–3.6s, two of them inside Skia allocations (Paint_nMake blocked 1.28s). The Java heap held steady at ~55 MB used, so the 1.7 GB footprint is not a Java leak.

The native image starts git with a real fork(), so on macOS every git run briefly takes the allocator and Objective-C runtime locks away from the UI thread.

What changed

  • Worktree checks are cached by commit (WorktreeOps). Each scan of a repo with linked worktrees re-checks every sibling, and linked worktrees are usually tracked as repos themselves, so a scan of any of them redid the work for all the others. "Contained in mainline", "ahead by N", "squash-merged" and "merged" now come from a cache keyed on the tree's path, its HEAD, its own branch, and mainline's ref and commit. The uncommitted-file count, last-commit date and remote state are still read every scan. After the first scan an unchanged sibling costs status plus log -1 instead of 6–10 commands.
  • Read-only git runs with GIT_OPTIONAL_LOCKS=0 (Git.read / readOrNull / exitCode). Plain git status rewrites .git/index when it refreshes its stat cache. The repo watch is recursive and includes .git, so a scan could trigger the next scan. Mutations through Git.run are unchanged.
  • Nucleus's single-instance lock gets its own directory (Main.kt), next to registry.json. By default it watches java.io.tmpdir. On macOS the JDK's WatchService polls, which meant calling lstat on everything in $TMPDIR every few seconds. This is fixed upstream (NucleusFramework/Nucleus@76d76c68b7), but only on the 3.0 line, not in any 2.5.x release.

For review

  • Cache staleness: hasOwnWork reads the branch reflog, which only gains entries when the branch moves. The branch points at HEAD, so a move is a new key.
  • Mixed versions: the new lock directory means this build and a running 1.4.0/1.5.0 won't see each other as the same instance. That only matters while both are installed.
  • A heap cap was considered and dropped. With ~2.5 young collections a second, a smaller heap would mean more collections, not less work. Lowering the allocation rate (fewer git runs) is the lever.
  • Not measured on the reporter's machine yet. After it ships, the same perf log (git counts vs scan.repo, watch.rescans, GC counts) will show the effect.

Tests

Three new WorktreeOpsTest cases cover the cache: it updates on a new commit in the tree and on mainline merging the branch, and the uncommitted-file count is read fresh while commits stand still. All 260 tests pass, confirmed in the result XML.

Diagnostics: git output size

Each git subcommand now also records how much it returned, as git.<subcommand>.chars in the perf log (only while collection is on). This is to answer what the reporter's GC rate is made of: about 2.5 young collections a second against a ~410 MB young generation, roughly 1 GB/s. A 44-repo, 20-worktree fixture churning at the same scan rate allocates 1–2 MB/s, on the JVM and in the 1.4.0 native image alike. On that fixture, git output totalled ~120 K characters over 3 minutes.

The same fixture run confirms the worktree cache: in a minute of 36 scans, diff, merge-base and rev-list went from 288/192/96 runs (pre-fix) to 0, and the mean scan time from 45 ms to 24 ms.

rocketraman and others added 2 commits November 3, 2026 10:29
A user on macOS reported lag after the app had run for a day. A process
sample and a perf log showed about 65 git processes a second, roughly
115 per repo scan. On macOS each one is a fork(), which holds the
allocator and Objective-C locks the UI thread needs, so the UI froze in
plain allocations for up to 3.6s.

Three changes:

- Re-checking every sibling worktree on each scan was most of those
  processes, and linked worktrees are usually tracked as repos of their
  own, so scanning any of them repeated it for all the others. Whether
  a tree's branch is contained in mainline, ahead of it or merged is
  now remembered by the tree's HEAD and mainline's commit. An unchanged
  sibling costs status and log, not six to ten commands.
- Read-only git runs with GIT_OPTIONAL_LOCKS=0. git status rewrote
  .git/index after refreshing its stat cache, and the recursive watch
  saw that as a change, so a scan could schedule the next one.
- Nucleus's single-instance lock lives in its own directory instead of
  the temp dir. On macOS the JDK watch service polls, so it was
  stat'ing every file in $TMPDIR every few seconds.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Raman Gupta <rocketraman@gmail.com>
The perf log could say how often git ran and for how long, but not how
much it handed back. A field report shows about 1GB/s allocated, and a
44-repo fixture at the same scan rate allocates 1-2MB/s, on the JVM
and in the native image alike. What real repos return is the
remaining difference, so each subcommand now also records its output
size as git.<subcommand>.chars while collection is on.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Raman Gupta <rocketraman@gmail.com>
@rocketraman
rocketraman merged commit 47894a1 into master Nov 3, 2026
2 checks passed
@rocketraman
rocketraman deleted the claude/gitvantage-lag-apple-silicon-97ec14 branch November 3, 2026 16:46
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.

1 participant