Fix Git ref refresh resource storms#4727

merged8
main
t3code/git-ref-refresh-resource-storm
openedJul 28, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago

What changed

  • remove the five-second client-side ref polling loop and refresh once per connection generation
  • expire idle ref atoms after 30 seconds
  • key repository/ref snapshots by the common Git directory
  • coalesce duplicate refreshes, single-flight full scans, and bound Git subprocess concurrency
  • replace separate local/remote ref enumeration with one for-each-ref call
  • invalidate cached snapshots after ref mutations

Root cause

Each retained vcs.listRefs atom polled independently. On this repository that multiplied 31 concurrent requests into repeated full scans across 838 worktrees and roughly 16,700 refs. The server had no shared snapshot, single-flight guard, or concurrency bound, so identical requests launched many expensive git branch, git worktree list, and ref-enumeration processes at once.

Impact

Ref lists remain fresh on connection changes and explicit refreshes, while background idle work no longer creates a Git subprocess storm. Mutations still invalidate snapshots immediately.

Verification

  • 34 focused client/server tests
  • targeted formatting, lint, and affected-package typechecks
  • integrated web verification: at most two expensive Git children and one worktree scan
  • integrated mobile idle verification: zero expensive Git samples across 60 settled samples

Co-authored-by: codex codex@users.noreply.github.com

Note

Fix Git ref refresh resource storms with coalesced snapshots, exponential backoff, and invalidation hooks

  • Replaces periodic polling for VCS refs with a single refresh per connection generation, retried on failure using exponential backoff (1s base, 30s cap), and a 30s idle TTL instead of 5 minutes.
  • Adds a coalesced, cached listRefs snapshot in GitVcsDriverCore so concurrent paginated calls share one underlying for-each-ref scan, with generation tracking to prevent stale cache reuse.
  • Invalidates cached VCS refs (both in-memory and persisted) after any ref-affecting mutation (pull, push, createRef, switchRef, worktree ops, publish, etc.) on both success and failure via new onSettled hooks on RPC commands.
  • Adds removeVcsRefs and clearVcsRefs to EnvironmentCacheStore (web IndexedDB, mobile SQLite) and a per-environment persistence lock to serialize cache mutations.
  • Fixes branch list pagination in BranchToolbarBranchSelector to only trigger next-page loads on downward scroll past a threshold, not on layout or array length changes.
  • Risk: Idle list-refs atoms now expire after 30s instead of 5 minutes, causing more frequent re-fetches when the atom is re-subscribed after a period of inactivity.

Macroscope summarized 2c4c29c.


Note

Medium Risk Large changes to GitVcsDriverCore caching, invalidation, and client ref streams affect branch lists and worktree-heavy repos; behavior is heavily tested but touches core VCS paths.

Overview Stops Git ref refresh from spawning subprocess storms by replacing per-atom 5s polling with one refresh per connection generation (exponential retry, 30s idle atom TTL) and a server-side coalesced snapshot keyed by common Git dir (for-each-ref + worktree metadata instead of separate branch listings).

Invalidation: VCS mutations (pull, worktree, create/switch ref, publish, stacked actions) clear persisted vcs-refs via new clearVcsRefs / removeVcsRefs on web and mobile stores, bump a per-environment revision, and invalidate server snapshots after Git-changing RPCs; listRefs accepts optional refresh: true (e.g. PR local branch lookup).

UI: Branch picker pagination loads the next page only on downward scroll near the bottom, with a dedicated isFetchingNextPage flag so background refreshes are not shown as β€œloading more.”

Reviewed by Cursor Bugbot for commit 2c4c29ca4c1909ff5610c175714d3830e647bf34. Bugbot is set up for automated code reviews on this repo. Configure here.

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

βš™οΈ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf4cae2f-84f1-4725-a1b6-03f7acf28615

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • πŸ” Trigger review
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/git-ref-refresh-resource-storm

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

……@@ -104,34 +104,30 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
120- ...safeErrorLogAttributes(error),
121- }),
122- Effect.as(Option.none<VcsListRefsResult>()),
123- ),
CcursorBoton line 123 (old)Jul 28, 2026

Reconnect refetch skips server refresh

Medium Severity

On each connection-generation refetch, makeCachedVcsRefsChanges calls vcs.listRefs with the subscribed input unchanged. Atoms that omit refresh: true (for example DiffPanel local/remote base-ref queries with refKind and includeMatchingRemoteRefs) now receive cached server snapshots for up to two minutes instead of a Git rescan. Removing the five-second poll makes out-of-band ref updates visible much later on those paths.

Reviewed by Cursor Bugbot for commit 8fb87fd77be389089c439e78bb82e25c66d843bc. Configure here.

apps/server/src/vcs/GitVcsDriverCore.tsline 2283Outdated Β· 8fb87fdOpen thread
……@@ -1987,257 +2070,226 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2280+ current:
2281+ repositoryPaths.worktreeRoot !== null &&
2282+ ref.worktreePath === repositoryPaths.worktreeRoot,
2283+ }));
CcursorBoton line 2283Jul 28, 2026

Current branch never marked

Medium Severity

current is set only when ref.worktreePath equals cached worktreeRoot. If git worktree list fails or rev-parse --show-toplevel once fails, worktreeRoot stays null and the worktree map is empty, so no ref is marked current even though the repo and branches are valid.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 8fb87fd77be389089c439e78bb82e25c66d843bc. Configure here.

Fixed in f2449b5152. Repository path discovery now also captures symbolic-ref HEAD, and listRefs uses it only as a fallback when the worktree snapshot cannot identify the current branch. The normal worktree mapping still wins, and mutation invalidation refreshes the fallback branch state. A focused test forces both worktree metadata calls to fail and verifies the active branch remains marked current.

apps/server/src/vcs/GitVcsDriverCore.tsline 2195Outdated Β· 8fb87fdOpen thread
……@@ -1987,257 +2070,226 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2192+ localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
2193+ remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
2194+ hasPrimaryRemote: remoteNames.includes("origin"),
2195+ } satisfies GitRefsSnapshot;
CcursorBoton line 2195Jul 28, 2026

Ignored for-each-ref failures

High Severity

readGitRefsSnapshot parses refsResult.stdout without checking refsResult.exitCode or truncation, unlike the remote, default-ref, and worktree lookups. Because Git subprocess calls allow non-zero exits, a failed for-each-ref can produce an empty snapshot that listRefs still serves with isRepo: true and may cache for minutes.

Reviewed by Cursor Bugbot for commit 8fb87fd77be389089c439e78bb82e25c66d843bc. Configure here.

Confirmed that executeGit already turns a non-zero exit into GitCommandError when allowNonZeroExit is omitted; the snapshot call does omit it. I made that contract explicit with a fallback error detail and added a regression test that forces for-each-ref to exit 128 and verifies listRefs fails with GitCommandError instead of caching an empty repository snapshot.

Synced from GitHub

Approvability

Verdict: Needs human review

This PR introduces substantial new caching infrastructure for Git ref operations, including multi-layer caches with TTL management, coalescing of concurrent requests, environment-wide invalidation on mutations, and exponential backoff for failures. The complexity of the new state management and fundamental changes to ref fetching behavior warrant human review.

No code changes detected at 2c4c29c. Prior analysis still applies.

You can customize Macroscope's approvability policy. Learn more.

apps/server/src/vcs/GitVcsDriverCore.tsline 1109Outdated Β· 501a738Open thread
……@@ -944,28 +990,138 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1106+ return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(
1107+ Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),
1108+ Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),
1109+ Effect.as(true as const),
CcursorBoton line 1109Jul 28, 2026

Backoff cleared on failed fetch

Medium Severity

refreshStatusRemoteCacheEntry clears the consecutive failure counter whenever fetchRemoteForStatus completes as a successful Effect, but that helper uses allowNonZeroExit, so a git fetch that exits with an error still runs clearStatusRemoteRefreshFailures. That resets exponential backoff after earlier spawn or timeout failures and stores a success TTL instead of backing off.

Reviewed by Cursor Bugbot for commit 501a7381787030d9adec0bd871f7966438e0e75d. Configure here.

Fixed in f2449b5152. Background fetch no longer allows non-zero exits, so a failed git fetch is retained as a cache failure and advances the exponential cooldown instead of clearing it. The backoff test now exercises repeated non-zero child exits at 30s and 60s boundaries.

……@@ -82,15 +82,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
89- ? Effect.succeed(Option.none<VcsListRefsResult>())
90- : Effect.succeed(cached),
91- ),
92- ),
CcursorBoton line 92 (old)Jul 28, 2026

Stale refs after refresh failure

Medium Severity

The refs stream always emits persisted loadVcsRefs data first, including while connected. If the subsequent live vcsListRefs call fails, the stream emits nothing further, so branch-list atoms can keep showing outdated refs (wrong current branch or missing branches) until reconnect or cache expiry.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 501a7381787030d9adec0bd871f7966438e0e75d. Configure here.

Fixed in f2449b5152. While connected, the client now retries a failed live ref refresh with capped exponential backoff instead of leaving persisted refs indefinitely. switchMap cancels the in-flight refresh and its retry timer on disconnect or generation change, and the server caches snapshot failures for 30s so retries cannot recreate a Git subprocess storm. Focused tests cover connected recovery and cancellation.

apps/server/src/vcs/GitVcsDriverCore.tsline 236Outdated Β· f2449b5Open thread
……@@ -210,6 +227,37 @@ function paginateBranches(input: {
233+ let currentBranch: string | null = null;
234+ let currentPrunable = false;
235+
236+ const flush = () => {
MmacroscopeappBoton line 236Jul 28, 2026

🟑 Medium vcs/GitVcsDriverCore.ts:236

parseWorktreeBranchPaths returns worktree paths without verifying they exist on disk. Git omits the prunable marker until its prune-expiry threshold elapses, so a worktree directory deleted manually still appears as a valid entry β€” the returned map points the branch at a nonexistent path. Downstream branch selection can then try to reuse that stale worktree instead of checking out the branch normally. The previous implementation explicitly stated each path, which dropped these entries. Consider re-statting each path before adding it to the map (or pruning stale worktrees first) so deleted directories are not treated as usable.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/vcs/GitVcsDriverCore.ts around line 236:
2
3`parseWorktreeBranchPaths` returns worktree paths without verifying they exist on disk. Git omits the `prunable` marker until its prune-expiry threshold elapses, so a worktree directory deleted manually still appears as a valid entry β€” the returned map points the branch at a nonexistent path. Downstream branch selection can then try to reuse that stale worktree instead of checking out the branch normally. The previous implementation explicitly `stat`ed each path, which dropped these entries. Consider re-statting each path before adding it to the map (or pruning stale worktrees first) so deleted directories are not treated as usable.

Fixed in 6caf60955e. Ref snapshots now stat every non-prunable worktree path with bounded concurrency and omit entries that no longer exist. The regression test supplies a stale porcelain entry for a deleted directory and verifies the branch is returned without a reusable worktree path.

apps/server/src/vcs/GitVcsDriverCore.tsline 2348Outdated Β· f2449b5Open thread
……@@ -1987,257 +2129,233 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2345+ ...ref,
2346+ current: hasCurrentWorktreeBranch
2347+ ? ref.worktreePath === repositoryPaths.worktreeRoot
2348+ : ref.name === repositoryPaths.currentBranch,
CcursorBoton line 2348Jul 28, 2026

Stale cached current branch

Medium Severity

listRefs rebuilds the shared ref snapshot when refresh is true, but it still marks current using repositoryPathsCache (currentBranch / worktreeRoot) with a ten-minute TTL. That cache is only cleared in invalidateListRefsSnapshot for the mutation’s cwd, so a fresh branch list can show the wrong current ref after checkout or rename until the cache expires.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit f2449b5152a05b3fa0fd90b129935cd608381f9b. Configure here.

Fixed in 6caf60955e. Explicit ref refreshes now also refresh repository identity through a five-second singleflight cache, keeping current-branch data fresh without multiplying Git subprocesses during concurrent refreshes. The regression test performs a raw external checkout and verifies the refreshed list moves the current marker.

……@@ -107,31 +106,19 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
121- }),
122- Effect.as(Option.none<VcsListRefsResult>()),
123- ),
124- ),
CcursorBoton line 124 (old)Jul 28, 2026

Pull leaves ref lists stale

Medium Severity

Replacing the five-second ref polling with one live vcsListRefs per connection generation means branch-list atoms stop updating unless something explicitly refreshes them. Server-side pull still invalidates ref snapshots, but pull success handlers only refresh git status, so branch pickers can show outdated refs (including remotes) until reconnect or a manual branch refresh.

Reviewed by Cursor Bugbot for commit 45ac080cc4eef3a08b13bc6b7f079116974dc2cf. Configure here.

Fixed in 2a9d07ea73: successful ref-mutating commands now bump a repository-scoped invalidation atom. Every mounted ref query for that environment/cwd depends on that revision, so pull/create/switch/worktree/init refresh once without restoring polling, while the persisted result keeps selectors populated during revalidation. Added focused invalidation coverage.

packages/client-runtime/src/state/vcs.tsline 185Outdated Β· 2a9d07eOpen thread
……@@ -160,6 +171,18 @@ export function createVcsEnvironmentAtoms<R, E>(
182+ environmentId: target.environmentId,
183+ cwd: target.input.cwd,
184+ });
185+ };
CcursorBoton line 185Jul 28, 2026

Ref invalidation misses list cwd

Medium Severity

invalidateRefs bumps a revision keyed only by the mutation RPC’s input.cwd, while listRefs atoms depend on the same key using each query’s input.cwd. Linked worktrees often use different paths for mutations versus branch lists, so successful pulls and checkouts can leave active ref streams stale until idle TTL, reconnect, or a manual refresh.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 2a9d07ea73a787280ddc661f26e832c867ca58cd. Configure here.

Fixed in dfc1459eec: mutation invalidation is now environment-scoped, so every mounted ref stream in that environment revalidates once regardless of which linked-worktree cwd issued the command. Other environments remain isolated, covered by the focused regression.

packages/client-runtime/src/state/vcs.tsline 243Outdated Β· dfc1459Open thread
……@@ -193,30 +212,35 @@ export function createVcsEnvironmentAtoms<R, E>(
217240 tag: WS_METHODS.vcsInit,
218241 scheduler: vcsCommandScheduler,
219242 concurrency: vcsCommandConcurrency,
243+ onSuccess: invalidateRefs,
CcursorBoton line 243Jul 28, 2026

Stacked commits skip ref invalidation

High Severity

The new mutation-driven invalidation for VCS listRefs atoms is incomplete. gitRunStackedAction and publishRepository don't trigger invalidateRefs on success, leading to stale branch lists and remote status. Additionally, listRefs atoms with filtered inputs may not refresh repository identity or snapshots from the server on reconnect, causing current-branch metadata to lag.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit dfc1459eec125c057291b04910c3a44cbdf9b6ed. Configure here.

Fixed in 561c035782. Ref invalidation now uses one module-scoped environment revision shared by the VCS, stacked-action, and source-control atom factories. Successful gitRunStackedAction and publishRepository commands both increment it, with direct command-level regression tests. Connected/reconnected list requests also force refresh: true, including filtered requests, so repository identity and snapshots are refreshed before applying query/pagination filters.

packages/client-runtime/src/state/vcsAction.tsline 497Outdated Β· 561c035Open thread
……@@ -489,6 +490,11 @@ export function createVcsActionManager<R, E>(
494+ Effect.sync(() => {
495+ invalidateVcsRefs(registry, target);
496+ }),
497+ ),
CcursorBoton line 497Jul 28, 2026

Failed stacked actions skip ref invalidation

Medium Severity

runStackedAction bumps the environment ref revision only when the stacked-action stream completes successfully. If Git already applied an earlier phase (such as creating a feature branch) and a later phase ends in action_failed, the client keeps serving cached ref streams with no reconnect and no periodic polling, so new branches can stay missing while the session remains connected.

Reviewed by Cursor Bugbot for commit 561c035782fce4bbd1901f8213f4cc6f8393d43b. Configure here.

Fixed in 4f3a66092b. Stacked-action ref invalidation now runs in Effect.ensuring, so success, failure after a mutating phase, and interruption all clear persisted refs and advance the live ref revision. The focused test now covers both the completed and action_failed terminal paths.

packages/client-runtime/src/state/vcs.tsline 93Outdated Β· 561c035Open thread
……@@ -82,15 +90,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
91- ),
92- ),
93- ).pipe(
93+ const cachedRefs = Stream.fromEffect(Effect.succeed(cached)).pipe(
CcursorBoton line 93Jul 28, 2026

Stale persisted refs after invalidation

Medium Severity

After a successful ref mutation bumps vcsRefsRevisionAtom, remounted ref streams still emit the on-disk loadVcsRefs snapshot before the live refresh completes. That snapshot was saved from the pre-mutation fetch, so branch UI can briefly revert to outdated refs right after create, switch, or pull succeeds.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 561c035782fce4bbd1901f8213f4cc6f8393d43b. Configure here.

Fixed in 4f3a66092b. Ref invalidation now removes the persisted (environmentId, cwd) snapshot before incrementing the revision that restarts the live stream, so a remount during the refresh window cannot replay stale branch data. The web and mobile cache adapters both implement the removal operation, with focused persistence and invalidation tests.

packages/client-runtime/src/state/vcs.tsline 154Outdated Β· 4f3a660Open thread
……@@ -160,6 +151,14 @@ export function createVcsEnvironmentAtoms<R, E>(
160151 readonly environmentId: EnvironmentId;
161152 readonly input: VcsListRefsInput;
162153 }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input));
154+ const invalidateRefs = (
MmacroscopeappBoton line 154Jul 28, 2026

🟑 Medium state/vcs.ts:154

invalidateRefs only deletes the persisted cache entry for the cwd of the worktree that performed the mutation, even though Git refs are shared across all linked worktrees. When a command mutates refs in worktree A while worktree B's atom is idle, B's persisted entry survives; remounting B later replays the stale branch list before its live refresh completes. The revision bump restarts only currently retained atoms but does not remove persisted entries stored under other worktree paths. Invalidation should remove all cache keys for the repository, or persistence should key them by a shared repository identity rather than per-cwd.

Also found in 2 other location(s)

packages/client-runtime/src/state/runtime.ts:651

The new onSuccess hook invalidates only the cache entry for target.input.cwd. Git ref mutations affect every worktree sharing the repository, while persisted vcs-refs entries are keyed by each worktree's cwd; an idle/unmounted sibling worktree therefore retains its stale record and can replay obsolete refs when remounted. The revision bump refreshes mounted atoms, but does not delete those other persisted entries.

packages/client-runtime/src/state/vcsRefInvalidation.ts:41

invalidateCachedVcsRefs removes only the persisted entry for target.cwd, although ref mutations (create/switch/push/stacked actions) can change repository-wide refs visible from every linked worktree. The environment-wide revision restarts all streams, but caches stored under other worktree paths remain and can later be replayed while offline, showing branches that predate the mutation. Invalidation needs to remove all cache keys for the repository (or persistence must key them by a shared repository identity).

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/client-runtime/src/state/vcs.ts around line 154:
2
3`invalidateRefs` only deletes the persisted cache entry for the `cwd` of the worktree that performed the mutation, even though Git refs are shared across all linked worktrees. When a command mutates refs in worktree A while worktree B's atom is idle, B's persisted entry survives; remounting B later replays the stale branch list before its live refresh completes. The revision bump restarts only currently retained atoms but does not remove persisted entries stored under other worktree paths. Invalidation should remove all cache keys for the repository, or persistence should key them by a shared repository identity rather than per-`cwd`.
4
5Also found in 2 other location(s):
6- packages/client-runtime/src/state/runtime.ts:651 -- The new `onSuccess` hook invalidates only the cache entry for `target.input.cwd`. Git ref mutations affect every worktree sharing the repository, while persisted `vcs-refs` entries are keyed by each worktree's `cwd`; an idle/unmounted sibling worktree therefore retains its stale record and can replay obsolete refs when remounted. The revision bump refreshes mounted atoms, but does not delete those other persisted entries.
7- packages/client-runtime/src/state/vcsRefInvalidation.ts:41 -- `invalidateCachedVcsRefs` removes only the persisted entry for `target.cwd`, although ref mutations (create/switch/push/stacked actions) can change repository-wide refs visible from every linked worktree. The environment-wide revision restarts all streams, but caches stored under other worktree paths remain and can later be replayed while offline, showing branches that predate the mutation. Invalidation needs to remove all cache keys for the repository (or persistence must key them by a shared repository identity).

Fixed in ff4df7fe63. Ref invalidation now clears the entire persisted VCS-ref cache namespace for the environment, so linked worktrees cannot replay stale entries, while unrelated thread/shell/settings caches are preserved. Added mobile persistence coverage for two worktree keys plus another environment; focused tests and web/mobile/client-runtime typechecks pass.

packages/client-runtime/src/state/vcsRefInvalidation.tsline 52Outdated Β· 4f3a660Open thread
……@@ -0,0 +1,53 @@
49+ ),
50+ ),
51+ );
52+ invalidateVcsRefs(registry, target);
CcursorBoton line 52Jul 28, 2026

Cross-worktree stale persisted refs

Medium Severity

The invalidateCachedVcsRefs function clears the persisted VCS ref cache only for the specific cwd it's called with. Because it triggers an environment-wide revision bump, other linked worktrees in the same environment can briefly display stale branch data from their local cache until live data loads.

Reviewed by Cursor Bugbot for commit 4f3a66092b7edf4f27fc45a24ad79fbb501d7a3f. Configure here.

……@@ -82,15 +90,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
87- Effect.flatMap((connection) =>
88- connection.phase === "connected"
89- ? Effect.succeed(Option.none<VcsListRefsResult>())
90- : Effect.succeed(cached),
CcursorBoton line 90 (old)Jul 28, 2026

Late refresh repersists stale refs

Medium Severity

An in-flight vcsListRefs refresh can finish after a ref mutation runs clearVcsRefs and bumps the environment revision. refresh always calls saveVcsRefs with whatever the RPC returns, without tying the write to the current revision or cancelling superseded requests, so a pre-mutation snapshot can be written back to persistence and replayed on remount.

Reviewed by Cursor Bugbot for commit ff4df7fe6374103df56fa648a4a8584a3af54b4c. Configure here.

Fixed in 4ba5ea3a5d. Each refresh now captures the environment ref revision, and its result is committed/emitted only while holding the same per-environment lock used by invalidation. Invalidation advances the revision and clears persisted refs under that lock, so either a prior save is subsequently cleared or a late result observes the new revision and is discarded. Added regression coverage for stale rejection and current-generation persistence; focused tests plus client-runtime/web/mobile typechecks pass.

packages/client-runtime/src/state/vcsRefInvalidation.tslines 53–55Outdated Β· 4ba5ea3Open thread
……@@ -0,0 +1,68 @@
52+ target.environmentId,
53+ Effect.gen(function* () {
54+ invalidateVcsRefs(registry, target);
55+ yield* cache.clearVcsRefs(target.environmentId).pipe(
MmacroscopeappBoton lines 53–55Jul 28, 2026

🟑 Medium state/vcsRefInvalidation.ts:53

invalidateCachedVcsRefs bumps the revision atom before cache.clearVcsRefs finishes, so active ref streams restart synchronously, call loadVcsRefs outside the lock, and emit the stale persisted snapshot before it is deleted. After a successful Git mutation, users can temporarily be shown the pre-mutation branch list. Swap the order so the cache is cleared before the revision is published β€” the surrounding persistence lock already prevents stale refresh writes.

suggestion
1 yield* cache.clearVcsRefs(target.environmentId).pipe(
2 Effect.catch((error) =>
3 Effect.logWarning("Could not remove invalidated cached Git refs.").pipe(
4 Effect.annotateLogs({
5 environmentId: target.environmentId,
6 cwd: target.cwd,
7 ...safeErrorLogAttributes(error),
8 }),
9 ),
10 ),
11 );
12 invalidateVcsRefs(registry, target);
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/client-runtime/src/state/vcsRefInvalidation.ts around lines 53-55:
2
3`invalidateCachedVcsRefs` bumps the revision atom before `cache.clearVcsRefs` finishes, so active ref streams restart synchronously, call `loadVcsRefs` outside the lock, and emit the stale persisted snapshot before it is deleted. After a successful Git mutation, users can temporarily be shown the pre-mutation branch list. Swap the order so the cache is cleared before the revision is published β€” the surrounding persistence lock already prevents stale refresh writes.

Fixed in 25c0c7abc8. Invalidation now clears the persisted environment-wide ref namespace while holding the shared persistence lock, and only then publishes the new revision that restarts active streams. The regression test records the revision observed by and verifies it remains at the old generation until clearing completes.

Correction: fixed in 25c0c7abc8. Invalidation now clears the persisted environment-wide ref namespace while holding the shared persistence lock, and only then publishes the new revision that restarts active streams. The regression test records the revision observed by clearVcsRefs and verifies it remains at the old generation until clearing completes.

apps/server/src/vcs/GitVcsDriverCore.tsline 246Outdated Β· 25c0c7aOpen thread
……@@ -210,6 +228,37 @@ function paginateBranches(input: {
243+ currentPrunable = false;
244+ };
245+
246+ for (const line of stdout.split("\n")) {
MmacroscopeappBoton line 246Jul 28, 2026

🟑 Medium vcs/GitVcsDriverCore.ts:246

parseWorktreeBranchPaths splits git worktree list --porcelain output on \n, but worktree paths can legally contain newline characters. A worktree whose path contains a newline is split into multiple pseudo-lines, causing currentPath to be overwritten or misparsed, and the branch is dropped from worktreePaths. The caller then incorrectly reports worktreePath: null for that checked-out branch. Git's porcelain format requires parsing NUL-delimited (-z) output to handle paths with embedded newlines. Consider invoking the command with -z and parsing NUL-delimited records instead of splitting on \n.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/vcs/GitVcsDriverCore.ts around line 246:
2
3`parseWorktreeBranchPaths` splits `git worktree list --porcelain` output on `\n`, but worktree paths can legally contain newline characters. A worktree whose path contains a newline is split into multiple pseudo-lines, causing `currentPath` to be overwritten or misparsed, and the branch is dropped from `worktreePaths`. The caller then incorrectly reports `worktreePath: null` for that checked-out branch. Git's porcelain format requires parsing NUL-delimited (`-z`) output to handle paths with embedded newlines. Consider invoking the command with `-z` and parsing NUL-delimited records instead of splitting on `\n`.

Fixed in 7fa76c88e2. Worktree enumeration now requests git worktree list --porcelain -z and parses NUL-delimited fields/records, preserving embedded newlines. A backend integration test creates a real newline-containing worktree path and verifies the listed branch retains its canonical path.

packages/client-runtime/src/state/vcs.tsfileOutdated Β· 25c0c7aOpen thread

🟑 Medium

https://github.com/pingdotgg/t3code/blob/25c0c7abc81393ea9f52fb054823b928589327ba/packages/client-runtime/src/state/vcs.ts#L251-L256

pull only invalidates cached VCS refs in onSuccess, so a git pull that fetches new remote-tracking refs but then fails during merge/rebase leaves stale cached refs. The RPC reports failure, so onSuccess never fires, yet the remote refs have already changed β€” branch pickers keep showing the pre-pull snapshot until reconnect or another mutation. Consider invalidating on both success and failure (e.g. via Effect.tapBoth), or invalidating before the RPC so the next ref stream always re-fetches.

diff
1- onSuccess: invalidateRefs,
2+ onSettled: invalidateRefs,
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/client-runtime/src/state/vcs.ts around lines 251-256:
2
3`pull` only invalidates cached VCS refs in `onSuccess`, so a `git pull` that fetches new remote-tracking refs but then fails during merge/rebase leaves stale cached refs. The RPC reports failure, so `onSuccess` never fires, yet the remote refs have already changed β€” branch pickers keep showing the pre-pull snapshot until reconnect or another mutation. Consider invalidating on both success and failure (e.g. via `Effect.tapBoth`), or invalidating before the RPC so the next ref stream always re-fetches.

Fixed in 7fa76c88e2. The shared RPC command helper now supports an onSettled hook, and pull uses it to clear persisted refs and publish a new ref generation on success or failure. The focused client regression test forces pull to fail and verifies both cache clearing and the revision bump.

apps/server/src/vcs/GitVcsDriverCore.tsline 2344Outdated Β· 25c0c7aOpen thread
……@@ -1987,257 +2148,242 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2341+ yield* Cache.invalidate(listRefsRefreshSnapshotCache, repositoryPaths.gitCommonDir);
2342+ bumpListRefsEpoch(repositoryPaths.gitCommonDir);
2343+ yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey);
2344+ yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey);
CcursorBoton line 2344Jul 28, 2026

Coalesced refresh ignores invalidation

Medium Severity

A listRefs refresh singleflight started before a mutating command can still finish and satisfy waiters after invalidateListRefsSnapshot runs. Those callers may receive a snapshot taken before the mutation, so branch lists and current-branch markers stay wrong until another explicit refresh.

Reviewed by Cursor Bugbot for commit 25c0c7abc81393ea9f52fb054823b928589327ba. Configure here.

Fixed in 5a352e1a3a. Server ref-snapshot refreshes now carry a monotonic per-repository generation. Invalidation advances both generation and snapshot epoch before yielding; any older in-flight result detects the mismatch and retries through a generation-keyed single-flight, so it cannot commit stale data after the mutation. A deterministic backend regression test pauses the first snapshot after its ref scan, mutates refs, resumes it, and verifies the waiter retries and sees the new branch.

packages/client-runtime/src/state/vcsRefInvalidation.tsline 66Outdated Β· 5a352e1Open thread
……@@ -0,0 +1,69 @@
63+ ),
64+ ),
65+ );
66+ invalidateVcsRefs(registry, target);
CcursorBoton line 66Jul 28, 2026

Clear failure still rehydrates stale cache

Medium Severity

When clearVcsRefs fails, invalidation still bumps the ref revision and restarts live streams. makeCachedVcsRefsChanges emits whatever loadVcsRefs returns first, so a failed clear can replay pre-mutation branch snapshots until a live refresh succeedsβ€”especially misleading offline or on slow connections.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 5a352e1a3aa4a04b69f4169af4fa3b4c5d48bb07. Configure here.

Fixed in b3f2a38da2. Ref cache state now atomically tracks both revision and whether persisted snapshots are readable. A failed environment-wide clear publishes a new generation with persisted reads disabled, so restarted/offline streams cannot replay stale data; live refreshes can still repopulate storage. The regression test forces clear failure and verifies no persisted load occurs.

packages/client-runtime/src/state/vcs.tsline 297Outdated Β· 5a352e1Open thread
……@@ -193,30 +266,35 @@ export function createVcsEnvironmentAtoms<R, E>(
217294 tag: WS_METHODS.vcsInit,
218295 scheduler: vcsCommandScheduler,
219296 concurrency: vcsCommandConcurrency,
297+ onSuccess: invalidateRefs,
CcursorBoton line 297Jul 28, 2026

Failed mutations skip ref invalidation

High Severity

Several mutating RPCs clear persisted ref snapshots only via onSuccess, while pull uses onSettled. If Git already changed (for example publishRepository adds a remote then push fails), the RPC error skips cache clearing and revision bump, so mounted ref streams keep serving stale branch data until reconnect because background polling was removed.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 5a352e1a3aa4a04b69f4169af4fa3b4c5d48bb07. Configure here.

packages/client-runtime/src/state/vcsRefInvalidation.tsline 39Outdated Β· b3f2a38Open thread
……@@ -0,0 +1,83 @@
36+ target: VcsRefsInvalidationTarget,
37+ persistedCacheReadable?: boolean,
38+): void {
39+ registry.update(vcsRefsCacheStateAtom(target), (state) => ({
MmacroscopeappBoton line 39Jul 28, 2026

🟑 Medium state/vcsRefInvalidation.ts:39

invalidateVcsRefs makes persistedCacheReadable sticky: once cache.clearVcsRefs fails and sets it to false, nothing in invalidateCachedVcsRefs or ordinary revision-gated refreshes ever restores it to true. After a one-time persistence deletion error, later atom instances keep skipping the offline cache β€” which may contain fresh data written by a subsequent successful refresh β€” until another VCS mutation happens to clear successfully. A single clearVcsRefs failure thus disables offline branch data indefinitely. The persistedCacheReadable: false state should be transient, scoped to the current cache generation, so a successful refresh clears it.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/client-runtime/src/state/vcsRefInvalidation.ts around line 39:
2
3`invalidateVcsRefs` makes `persistedCacheReadable` sticky: once `cache.clearVcsRefs` fails and sets it to `false`, nothing in `invalidateCachedVcsRefs` or ordinary revision-gated refreshes ever restores it to `true`. After a one-time persistence deletion error, later atom instances keep skipping the offline cache β€” which may contain fresh data written by a subsequent successful refresh β€” until another VCS mutation happens to clear successfully. A single `clearVcsRefs` failure thus disables offline branch data indefinitely. The `persistedCacheReadable: false` state should be transient, scoped to the current cache generation, so a successful refresh clears it.

Fixed in 70b295c2b0. The fail-closed state is retained across UI unmounts, and an unfiltered live refresh now retries the environment-wide clear under the shared persistence lock. Only after that cleanup succeeds does it save the fresh snapshot and re-enable persisted reads; failed retries remain fail-closed. The regression test covers clear failure, unmounted/offline suppression, successful live recovery, and subsequent offline rehydration of the fresh snapshot.

packages/client-runtime/src/state/vcs.tsline 291Outdated Β· 70b295cOpen thread
……@@ -181,6 +288,7 @@ export function createVcsEnvironmentAtoms<R, E>(
181288 tag: WS_METHODS.vcsPull,
182289 scheduler: vcsCommandScheduler,
183290 concurrency: vcsCommandConcurrency,
291+ onSettled: invalidateRefs,
CcursorBoton line 291Jul 28, 2026

Refresh status skips ref invalidation

Medium Severity

refreshStatus is not wired to invalidateCachedVcsRefs, unlike other VCS mutations in this change. After a status refresh updates remote refs on the server, the client still serves the old persisted branch snapshot and does not run another live listRefs until the connection generation changes, so branch UI can stay wrong for a long-lived session.

Reviewed by Cursor Bugbot for commit 70b295c2b06f73a10c9e32805da4616898a679f7. Configure here.

Fixed in f948f0a634. refreshStatus now uses the same settlement invalidation as every other ref-affecting command, covering server-side remote refresh even when the operation fails after partial Git changes. The command-level regression test now verifies both failed pull and successful status refresh clear persistence and advance the ref generation.

apps/web/src/state/paginatedBranches.tslines 31–31Outdated Β· 33c01c5Open thread
……@@ -0,0 +1,36 @@
28+ clientHeight: number;
29+ distanceThreshold?: number;
30+}): boolean {
31+ if (previousScrollTop === null || scrollTop <= previousScrollTop + 1) {
MmacroscopeappBoton lines 31–31Jul 28, 2026

🟑 Medium state/paginatedBranches.ts:31

shouldLoadNextBranchPageAfterScroll never loads the next page during slow or smooth scrolling. The guard scrollTop <= previousScrollTop + 1 treats any downward movement of 1px or less as "not scrolling down," so successive subpixel or 1px scroll events are all rejected β€” even when the user reaches the bottom, fetchNextBranchPage is never called. Because scrollTop is subpixel-precise on modern browsers/trackpads, this is a common case, not an edge case. The direction check should reject only non-increasing movement (scrollTop <= previousScrollTop), not deltas <= 1.

suggestion
1 if (previousScrollTop === null || scrollTop <= previousScrollTop) {
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/state/paginatedBranches.ts around line 31:
2
3`shouldLoadNextBranchPageAfterScroll` never loads the next page during slow or smooth scrolling. The guard `scrollTop <= previousScrollTop + 1` treats any downward movement of 1px or less as "not scrolling down," so successive subpixel or 1px scroll events are all rejected β€” even when the user reaches the bottom, `fetchNextBranchPage` is never called. Because `scrollTop` is subpixel-precise on modern browsers/trackpads, this is a common case, not an edge case. The direction check should reject only non-increasing movement (`scrollTop <= previousScrollTop`), not deltas `<= 1`.

Fixed in 6deb060e97. The direction guard now rejects only non-increasing positions, so 1px and subpixel trackpad movement can trigger the next page at the threshold. The focused regression test covers a 0.5px downward movement at the boundary.

Added 8 commits

  • Handle subpixel ref list scrolling2c4c29c
  • Avoid forced Git ref scans329e648
  • Harden Git refresh failure recovery41f3c66
  • Fix Git ref refresh resource storms7f410c4
  • Refresh Git refs after mutationsa7f70e3
  • Refresh Git worktree identity safelyae75d1f
  • Gate ref pagination on user scrolld82cc53
  • Keep Git refresh state warm across remountse5ee295
CcursorBotreviewed Β· 1 inline threadJul 28, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit 2c4c29ca4c1909ff5610c175714d3830e647bf34. Configure here.

……@@ -0,0 +1,36 @@
32+ return false;
33+ }
34+
35+ return scrollHeight - scrollTop - clientHeight <= distanceThreshold;
CcursorBoton line 35Jul 28, 2026

Branch pages stuck without scroll

High Severity

Branch pagination now loads the next page only when shouldLoadNextBranchPageAfterScroll sees a strictly increasing scrollTop, and the branch picker no longer triggers loads from layout, list growth, or onEndReached. When the first page fits the list viewport (scrollHeight equals clientHeight), there is no downward scroll to satisfy the guard, so hasNextPage stays true but additional refs never load.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 2c4c29ca4c1909ff5610c175714d3830e647bf34. Configure here.

Not applicable for this picker. The web query fixes the page size at 100 (VCS_REF_LIST_LIMIT), while paginateBranches only returns a non-null nextCursor when more refs exist after refs.slice(cursor, cursor + limit). Therefore any first page with hasNextPage contains exactly 100 refs. The picker viewport is capped at 14rem (about 8 rows), and the unfiltered client path does not remove page items; filtered search is applied server-side before the same pagination. A hasNextPage page cannot fit this viewport, so a real downward scroll is always possible. Reintroducing mount/layout pagination here would recreate the user-reproduced self-feeding 100β†’200β†’300 ref storm.