Skip to content

perf(host-service): listBranches single git spawn instead of 4×N#4160

Merged
saddlepaddle merged 2 commits intomainfrom
debug-cpu-performance
May 7, 2026
Merged

perf(host-service): listBranches single git spawn instead of 4×N#4160
saddlepaddle merged 2 commits intomainfrom
debug-cpu-performance

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented May 7, 2026

Summary

  • listBranches was running 4 sequential git child processes per local branch (git config remote, git config merge, git rev-list --left-right, git log -1) for an O(N) fanout, but the only renderer consumer (BaseBranchSelector in the v2 sidebar) reads branch.name plus a UI check on isHead. Replaced with a single git for-each-ref refs/heads/ --format='%(HEAD)\t%(refname:short)' call — O(1) regardless of branch count.
  • useDiffStats was bypassing React Query (called client.git.getStatus.query() directly), so each sidebar workspace tile issued its own uncached getStatus per git:changed event with no possibility of cache sharing. Switched to workspaceTrpc.git.getStatus.useQuery() + invalidate-on-event so concurrent invalidations from sibling consumers collapse to one refetch. Same update semantics, shared cache.
  • Adds an investigation plan documenting the diagnostic methodology (sample(1) on the wrong PID, realising PID 2182 was the host-service running via ELECTRON_RUN_AS_NODE, in-process spawn tracer, root-cause).

For a repo with ~1k local branches: previously ~3700 spawns per listBranches call, now 1. With the v2 sidebar polling every 30s and invalidating on git events, this was sustained near-100% CPU on the host-service event loop.

Test plan

  • Open a v2 workspace, expand the Changes tab — BaseBranchSelector lists all local branches
  • Pick a different base branch from the picker — works as before, status updates
  • Make a file edit in the worktree — diff-stats badge on the dashboard sidebar tile updates within ~300ms
  • Multiple workspace tiles visible simultaneously — they all update on the same git:changed event without N independent refetches (verify with network tab or spawn-trace-style instrumentation)
  • CPU stays low while sitting on a Changes tab with the worktree idle

Summary by cubic

Cuts host-service CPU by collapsing git.listBranches to one spawn and moving diff stats to a shared React Query cache. Prevents multi-tile refetches on window focus and keeps the sidebar responsive.

  • Performance
    • Host-service: git.listBranches now uses git for-each-ref refs/heads/ --format='%(HEAD)\t%(refname:short)' to return { name, isHead } in one spawn.
    • Renderer: useDiffStats switches to workspaceTrpc.git.getStatus.useQuery() with invalidate on git:changed, so sibling consumers share one refetch.
    • Renderer: disables refetchOnWindowFocus to match previous behavior and avoid per-tile refetch storms.

Written for commit 7586b45. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • More reliable and timely diff statistics display by centralizing status updates and automatic refresh.
    • Faster and simpler branch listing with streamlined retrieval, improving responsiveness when viewing branches.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b20ba5c2-ab7c-45f6-aba1-f9b0833767b8

📥 Commits

Reviewing files that changed from the base of the PR and between 89ef65a and 7586b45.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts

📝 Walkthrough

Walkthrough

Two performance-related changes: the listBranches TRPC query reduced from sequential per-branch git invocations to a single batched git for-each-ref command returning minimal branch tuples; the renderer's useDiffStats hook refactored to consume the optimized endpoint via shared TRPC query caching with event-driven cache invalidation instead of maintaining independent fetch state.

Changes

listBranches Performance and Query Caching Integration

Layer / File(s) Summary
listBranches Batching Implementation
packages/host-service/src/trpc/router/git/git.ts
listBranches invokes single git for-each-ref refs/heads/ call to collect { name, isHead } tuples, replacing prior per-branch buildBranch fanout and base/current branch comparison logic.
Renderer Hook TRPC Caching
apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts
useDiffStats refactored to consume workspaceTrpc.git.getStatus query cache with memoized diff deduplication, invalidates on git:changed event, and returns null when no data available, removing direct host-service client instantiation and manual state/effect handling.

Sequence Diagram(s)

sequenceDiagram
  participant Hook as useDiffStats
  participant Query as workspaceTrpc.git.getStatus
  participant Cache as QueryCache
  participant Event as git:changed
  Hook->>Query: useQuery(refetchOnWindowFocus: false)
  Query->>Cache: store status
  Cache-->>Hook: return data or null
  Event->>Cache: invalidate getStatus
  Cache->>Query: auto-refetch
  Query-->>Hook: updated status
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 From many spawns to one swift call,
the branches gather, no more thrall.
With caching shared and events to wake,
the renderer sees what git commands make.
Optimization hops through both sides of the stack! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main performance optimization: collapsing listBranches from 4×N git spawns to a single spawn.
Description check ✅ Passed The description provides comprehensive context: specific O(N) → O(1) optimization details, React Query cache consolidation explanation, test plan checklist, and investigation methodology.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch debug-cpu-performance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

`listBranches` was running 4 git child processes per local branch
(`git config remote`, `git config merge`, `git rev-list --left-right`,
`git log -1`) for an O(N) fanout, but the only renderer consumer
(`BaseBranchSelector`) reads `branch.name` plus a UI check on `isHead`.
With ~1k local branches that was ~3-4k spawns per call, sustained
near-100% CPU on the host-service event loop. Replaced with a single
`git for-each-ref refs/heads/ --format='%(HEAD)\t%(refname:short)'`
call. Cost now O(1).

`useDiffStats` was bypassing React Query — calling the imperative
`client.git.getStatus.query()` per `git:changed` event, so each
sibling consumer (every sidebar tile) issued its own uncached
`getStatus`. Switched to `workspaceTrpc.git.getStatus.useQuery()` +
invalidate-on-event so concurrent invalidations collapse to one
refetch. Same update semantics, shared cache.

Plan doc captures the diagnostic methodology (sample(1) → realising
PID 2182 was the host-service via ELECTRON_RUN_AS_NODE → in-process
spawn tracer → root cause).
@saddlepaddle saddlepaddle force-pushed the debug-cpu-performance branch from fc254aa to 89ef65a Compare May 7, 2026 02:52
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 7, 2026

Greptile Summary

This PR fixes a sustained near-100% CPU storm on the host-service caused by listBranches spawning 3\u20134 git child processes per local branch (O(N) spawns), and by useDiffStats bypassing React Query and issuing uncached getStatus requests per git:changed event per tile.

  • listBranches (host-service): Replaces the buildBranch fan-out with a single git for-each-ref call, reducing spawn count from ~3700 (for 932 branches) to 1 regardless of branch count. The narrowed return type ({ name, isHead }) is safe \u2014 all consumers infer it via tRPC's inferRouterOutputs.
  • useDiffStats (renderer): Migrates to workspaceTrpc.git.getStatus.useQuery + invalidate-on-git:changed for shared-cache refetching, but omits refetchOnWindowFocus: false, inheriting React Query's default of true and reintroducing per-workspace getStatus spawns on every app-focus with multiple tiles open.
  • Investigation plan: New markdown document; status line and artifact-cleanup warning are stale relative to the branch state.

Confidence Score: 3/5

The listBranches fix is safe to merge; the useDiffStats migration introduces an unintended refetch-on-focus behaviour that partially undermines the load reduction.

The host-service router change is clean and well-reasoned. The useDiffStats hook omits refetchOnWindowFocus: false, silently reintroducing refetch-on-focus for getStatus across every visible workspace tile. With multiple workspaces open, sustained app-focus events can produce the same concurrent git spawns the PR aims to eliminate.

apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts — the missing refetchOnWindowFocus: false option needs a second look before merging.

Important Files Changed

Filename Overview
packages/host-service/src/trpc/router/git/git.ts Replaces the 4×N-spawn buildBranch fan-out in listBranches with a single git for-each-ref call, correctly parsing %(HEAD)\t%(refname:short) to populate { name, isHead }. The narrowed return type flows correctly through tRPC inference to all consumers.
apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts Migrates from a manual fetch/useState pattern to React Query via workspaceTrpc.git.getStatus.useQuery. The shared cache and invalidation approach is correct, but refetchOnWindowFocus is not explicitly disabled, so the hook now refetches getStatus on every window-focus event — behaviour that didn't exist before.
apps/desktop/plans/20260506-host-service-listbranches-cpu-storm.md New investigation document with thorough diagnostic methodology. Status line and artifact-cleanup warning are stale: the fix is implemented in this PR and the debug files are already absent from the branch.

Comments Outside Diff (2)

  1. apps/desktop/plans/20260506-host-service-listbranches-cpu-storm.md, line 7 (link)

    P2 Outdated status line and stale artifact warning

    The header reads Status: root cause confirmed, mitigation applied, fix not yet implemented, but this PR is the implementation. Readers who find this file after the merge will be misled into thinking the code fix is still pending. Similarly, the "Lingering artifacts in this branch" section (lines 141–148) warns that spawn-trace.ts and the import "./spawn-trace" line in index.ts must be removed before merging — but both are already absent from the branch.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/plans/20260506-host-service-listbranches-cpu-storm.md
    Line: 7
    
    Comment:
    **Outdated status line and stale artifact warning**
    
    The header reads `Status: root cause confirmed, mitigation applied, fix not yet implemented`, but this PR is the implementation. Readers who find this file after the merge will be misled into thinking the code fix is still pending. Similarly, the "Lingering artifacts in this branch" section (lines 141–148) warns that `spawn-trace.ts` and the `import "./spawn-trace"` line in `index.ts` must be removed before merging — but both are already absent from the branch.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. packages/host-service/test/integration/git.integration.test.ts, line 20-31 (link)

    P2 isHead field not covered by the integration test

    The existing test only asserts that branch.name values are present; it never checks isHead. The implementation now derives isHead from %(HEAD) in the for-each-ref output, and the parse logic has a fallback path (tab < 0 → isHead: false). A test asserting that the checked-out branch has isHead: true and "feature/x" has isHead: false would pin the new parsing behaviour against future format-string changes.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/host-service/test/integration/git.integration.test.ts
    Line: 20-31
    
    Comment:
    **`isHead` field not covered by the integration test**
    
    The existing test only asserts that `branch.name` values are present; it never checks `isHead`. The implementation now derives `isHead` from `%(HEAD)` in the `for-each-ref` output, and the parse logic has a fallback path (`tab < 0 → isHead: false`). A test asserting that the checked-out branch has `isHead: true` and `"feature/x"` has `isHead: false` would pin the new parsing behaviour against future format-string changes.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts:18-21
**Implicit `refetchOnWindowFocus` behavior change**

The old implementation only fetched on mount and on `git:changed` events — there was no window-focus refetch. `workspaceTrpc.git.getStatus.useQuery()` without an explicit `refetchOnWindowFocus: false` inherits React Query's default of `true`, so every time the user switches back to the app, each visible workspace tile issues its own `getStatus` call. `getStatus` spawns several git processes (status, numstat, diff, ls-files), so on window focus with N workspace tiles open the host-service still sees N concurrent `getStatus` spawns — exactly the multi-consumer refetch pattern the PR set out to fix. Passing `{ refetchOnWindowFocus: false }` alongside `{ enabled: Boolean(workspaceId) }` restores the previous event-only update semantics while keeping the shared-cache benefit.

### Issue 2 of 3
apps/desktop/plans/20260506-host-service-listbranches-cpu-storm.md:7
**Outdated status line and stale artifact warning**

The header reads `Status: root cause confirmed, mitigation applied, fix not yet implemented`, but this PR is the implementation. Readers who find this file after the merge will be misled into thinking the code fix is still pending. Similarly, the "Lingering artifacts in this branch" section (lines 141–148) warns that `spawn-trace.ts` and the `import "./spawn-trace"` line in `index.ts` must be removed before merging — but both are already absent from the branch.

### Issue 3 of 3
packages/host-service/test/integration/git.integration.test.ts:20-31
**`isHead` field not covered by the integration test**

The existing test only asserts that `branch.name` values are present; it never checks `isHead`. The implementation now derives `isHead` from `%(HEAD)` in the `for-each-ref` output, and the parse logic has a fallback path (`tab < 0 → isHead: false`). A test asserting that the checked-out branch has `isHead: true` and `"feature/x"` has `isHead: false` would pin the new parsing behaviour against future format-string changes.

Reviews (1): Last reviewed commit: "perf(host-service): listBranches single ..." | Re-trigger Greptile

Comment on lines +18 to +21
const { data: status } = workspaceTrpc.git.getStatus.useQuery(
{ workspaceId },
{ enabled: Boolean(workspaceId) },
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Implicit refetchOnWindowFocus behavior change

The old implementation only fetched on mount and on git:changed events — there was no window-focus refetch. workspaceTrpc.git.getStatus.useQuery() without an explicit refetchOnWindowFocus: false inherits React Query's default of true, so every time the user switches back to the app, each visible workspace tile issues its own getStatus call. getStatus spawns several git processes (status, numstat, diff, ls-files), so on window focus with N workspace tiles open the host-service still sees N concurrent getStatus spawns — exactly the multi-consumer refetch pattern the PR set out to fix. Passing { refetchOnWindowFocus: false } alongside { enabled: Boolean(workspaceId) } restores the previous event-only update semantics while keeping the shared-cache benefit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts
Line: 18-21

Comment:
**Implicit `refetchOnWindowFocus` behavior change**

The old implementation only fetched on mount and on `git:changed` events — there was no window-focus refetch. `workspaceTrpc.git.getStatus.useQuery()` without an explicit `refetchOnWindowFocus: false` inherits React Query's default of `true`, so every time the user switches back to the app, each visible workspace tile issues its own `getStatus` call. `getStatus` spawns several git processes (status, numstat, diff, ls-files), so on window focus with N workspace tiles open the host-service still sees N concurrent `getStatus` spawns — exactly the multi-consumer refetch pattern the PR set out to fix. Passing `{ refetchOnWindowFocus: false }` alongside `{ enabled: Boolean(workspaceId) }` restores the previous event-only update semantics while keeping the shared-cache benefit.

How can I resolve this? If you propose a fix, please make it concise.

The pre-RQ `useDiffStats` only fetched on mount and on `git:changed`
events. Switching to `useQuery` without an explicit
`refetchOnWindowFocus: false` inherited React Query's default of
`true`, so every window focus would issue one `getStatus` per visible
sidebar tile (each tile has its own `workspaceId` query key, so RQ
won't dedupe across tiles). With 8 tiles open that's ~112 git spawns
per focus event — exactly the multi-consumer fanout this hook was
supposed to eliminate.
@saddlepaddle saddlepaddle merged commit e37bb20 into main May 7, 2026
17 checks passed
saddlepaddle added a commit that referenced this pull request May 8, 2026
#4160 swapped useDiffStats from imperative client.git.getStatus.query()
to workspaceTrpc.git.getStatus.useQuery(). That broke the v2 dashboard
sidebar: workspaceTrpc.Provider only mounts inside v2-workspace/$id, but
the sidebar is a sibling of that Outlet — so its useQuery calls had no
provider, and even with one couldn't fan out to workspaces on different
hosts.

Resolve hostUrl per-workspaceId, call the imperative client inside a
react-query queryFn keyed on (hostUrl, workspaceId). Concurrent callers
(tile + hover overlay for the same workspace) still dedup via the global
QueryClient. staleTime: Infinity since git:changed is push-authoritative.
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