Fix Git ref refresh resource storms#4727
25 unresolved threads Β· Jump to file
- apps/web/src/state/paginatedBranches.ts:35 Β· ### Branch pages stuck without scroll **High Severity** <!-- DESCRIPTION START --> Branch paginati
- packages/client-runtime/src/state/vcs.ts:123 Β· ### Reconnect refetch skips server refresh **Medium Severity** <!-- DESCRIPTION START --> On each
- apps/server/src/vcs/GitVcsDriverCore.ts:2283 Β· ### Current branch never marked **Medium Severity** <!-- DESCRIPTION START --> `current` is set on
- packages/client-runtime/src/state/vcsRefInvalidation.ts:66 Β· ### Clear failure still rehydrates stale cache **Medium Severity** <!-- DESCRIPTION START --> When
- apps/server/src/vcs/GitVcsDriverCore.ts:246 Β· π‘ **Medium** `vcs/GitVcsDriverCore.ts:246` `parseWorktreeBranchPaths` splits `git worktree list --
- apps/web/src/state/paginatedBranches.ts:31β31 Β· π‘ **Medium** `state/paginatedBranches.ts:31` `shouldLoadNextBranchPageAfterScroll` never loads the
- packages/client-runtime/src/state/vcs.ts:90 Β· ### Late refresh repersists stale refs **Medium Severity** <!-- DESCRIPTION START --> An in-flight
- packages/client-runtime/src/state/vcs.ts:297 Β· ### Failed mutations skip ref invalidation **High Severity** <!-- DESCRIPTION START --> Several mu
- apps/server/src/vcs/GitVcsDriverCore.ts:2344 Β· ### Coalesced refresh ignores invalidation **Medium Severity** <!-- DESCRIPTION START --> A `listR
- packages/client-runtime/src/state/vcsRefInvalidation.ts:52 Β· ### Cross-worktree stale persisted refs **Medium Severity** <!-- DESCRIPTION START --> The `invali
- apps/server/src/vcs/GitVcsDriverCore.ts:236 Β· π‘ **Medium** `vcs/GitVcsDriverCore.ts:236` `parseWorktreeBranchPaths` returns worktree paths witho
- apps/server/src/vcs/GitVcsDriverCore.ts:2195 Β· ### Ignored for-each-ref failures **High Severity** <!-- DESCRIPTION START --> `readGitRefsSnapsho
- packages/client-runtime/src/state/vcsAction.ts:497 Β· ### Failed stacked actions skip ref invalidation **Medium Severity** <!-- DESCRIPTION START --> `r
- packages/client-runtime/src/state/vcs.ts Β· File comment Β· π‘ **Medium** https://github.com/pingdotgg/t3code/blob/25c0c7abc81393ea9f52fb054823b928589327ba/pac
- packages/client-runtime/src/state/vcsRefInvalidation.ts:39 Β· π‘ **Medium** `state/vcsRefInvalidation.ts:39` `invalidateVcsRefs` makes `persistedCacheReadable` s
- packages/client-runtime/src/state/vcs.ts:291 Β· ### Refresh status skips ref invalidation **Medium Severity** <!-- DESCRIPTION START --> `refreshS
- packages/client-runtime/src/state/vcs.ts:185 Β· ### Ref invalidation misses list cwd **Medium Severity** <!-- DESCRIPTION START --> `invalidateRef
- packages/client-runtime/src/state/vcs.ts:124 Β· ### Pull leaves ref lists stale **Medium Severity** <!-- DESCRIPTION START --> Replacing the five-
- packages/client-runtime/src/state/vcsRefInvalidation.ts:53β55 Β· π‘ **Medium** `state/vcsRefInvalidation.ts:53` `invalidateCachedVcsRefs` bumps the revision atom be
- apps/server/src/vcs/GitVcsDriverCore.ts:2348 Β· ### Stale cached current branch **Medium Severity** <!-- DESCRIPTION START --> `listRefs` rebuilds
- packages/client-runtime/src/state/vcs.ts:154 Β· π‘ **Medium** `state/vcs.ts:154` `invalidateRefs` only deletes the persisted cache entry for the `c
- packages/client-runtime/src/state/vcs.ts:243 Β· ### Stacked commits skip ref invalidation **High Severity** <!-- DESCRIPTION START --> The new mut
- apps/server/src/vcs/GitVcsDriverCore.ts:1109 Β· ### Backoff cleared on failed fetch **Medium Severity** <!-- DESCRIPTION START --> `refreshStatusR
- packages/client-runtime/src/state/vcs.ts:92 Β· ### Stale refs after refresh failure **Medium Severity** <!-- DESCRIPTION START --> The refs strea
- packages/client-runtime/src/state/vcs.ts:93 Β· ### Stale persisted refs after invalidation **Medium Severity** <!-- DESCRIPTION START --> After a
packages/client-runtime/src/state/vcs.ts Β· Earlier revision 8fb87fd7 Β· left 123
Original diff
@@ -104,34 +104,30 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
).pipe(
Stream.map((connection) => (connection.phase === "connected" ? connection.generation : null)),
Stream.changes,
- Stream.switchMap((generation) =>
- generation === null
- ? Stream.empty
- : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe(
- Stream.mapEffect(
- () =>
- refresh().pipe(
- Effect.map(Option.some),
- Effect.catch((error) =>
- Effect.logWarning("Could not refresh Git refs.").pipe(
- Effect.annotateLogs({
- environmentId,
- cwd: input.cwd,
- ...safeErrorLogAttributes(error),
- }),
- Effect.as(Option.none<VcsListRefsResult>()),
- ),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.ts Β· Earlier revision 8fb87fd7 Β· right 2283
Original diff
@@ -1987,257 +2070,226 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
);
- const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
- function* (input) {
- const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
- Effect.orElseSucceed(() => new Map<string, number>()),
- );
- const localBranchResult = yield* executeGitWithStableDiagnostics(
- "GitVcsDriver.listRefs.branchNoColor",
- input.cwd,
- ["branch", "--no-color", "--no-column"],
- {
- timeoutMs: 10_000,
+ const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) {
+ const fetchCwd =
+ path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
+ const gitDirArgs = ["--git-dir", gitCommonDir] as const;
+ const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all(
+ [
+ executeGitWithStableDiagnostics(
+ "GitVcsDriver.listRefs.snapshotRefs",
+ fetchCwd,
+ [
+ ...gitDirArgs,
+ "for-each-ref",
+ "--format=%(refname)%09%(committerdate:unix)%09%(symref)",
+ "refs/heads",
+ "refs/remotes",
+ ],
+ {
+ timeoutMs: 30_000,
+ maxOutputBytes: 16 * 1024 * 1024,
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.defaultRef",
+ fetchCwd,
+ [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"],
+ {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.worktreeList",
+ fetchCwd,
+ [...gitDirArgs, "worktree", "list", "--porcelain"],
+ {
+ timeoutMs: 30_000,
+ allowNonZeroExit: true,
+ maxOutputBytes: 16 * 1024 * 1024,
+ },
+ ),
+ executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
+ timeoutMs: 5_000,
allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- isMissingGitCwdError(error)
- ? Effect.succeed({
- exitCode: ChildProcessSpawner.ExitCode(128),
- stdout: "",
- stderr: "fatal: not a git repository",
- stdoutTruncated: false,
- stderrTruncated: false,
- })
- : Effect.fail(error),
}),
- );
+ ],
+ { concurrency: 2 },
+ );
- if (localBranchResult.exitCode !== 0) {
- const stderr = localBranchResult.stderr.trim();
- if (isNonRepositoryGitStderr(stderr)) {
- return {
- refs: [],
- isRepo: false,
- hasPrimaryRemote: false,
- nextCursor: null,
- totalCount: 0,
- };
- }
- return yield* new GitCommandError({
- ...gitCommandContext({
- operation: "GitVcsDriver.listRefs",
- cwd: input.cwd,
- args: ["branch", "--no-color", "--no-column"],
- }),
- detail: "Git branch listing failed.",
- exitCode: localBranchResult.exitCode,
- stdoutLength: localBranchResult.stdout.length,
- stderrLength: localBranchResult.stderr.length,
+ const remoteNames =
+ remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
+ if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
+ yield* Effect.logWarning(
+ `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ );
+ }
+ const defaultBranch =
+ defaultRefResult.exitCode === 0
+ ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
+ : null;
+ const worktreeMap =
+ worktreeListResult.exitCode === 0
+ ? new Map(
+ [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
+ ([branchName, worktreePath]) =>
+ [branchName, path.normalize(path.resolve(worktreePath))] as const,
+ ),
+ )
+ : new Map<string, string>();
+ const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+ const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+
+ for (const line of refsResult.stdout.split("\n")) {
+ if (line.length === 0) continue;
+ const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t");
+ if (!fullRefName || symbolicTarget) continue;
+ const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
+ const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0;
+
+ if (fullRefName.startsWith("refs/heads/")) {
+ const name = fullRefName.slice("refs/heads/".length);
+ localBranches.push({
+ ref: {
+ name,
+ current: false,
+ isRemote: false,
+ isDefault: name === defaultBranch,
+ worktreePath: worktreeMap.get(name) ?? null,
+ },
+ lastCommit,
});
+ continue;
}
+ if (!fullRefName.startsWith("refs/remotes/")) continue;
+
+ const name = fullRefName.slice("refs/remotes/".length);
+ const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames);
+ const remoteBranch: VcsRef = {
+ name,
+ current: false,
+ isRemote: true,
+ isDefault:
+ defaultBranch !== null &&
+ parsedRemoteRef?.remoteName === "origin" &&
+ parsedRemoteRef.branchName === defaultBranch,
+ worktreePath: null,
+ ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}),
+ };
+ remoteBranches.push({ ref: remoteBranch, lastCommit });
+ }
- const remoteBranchResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteBranches",
- input.cwd,
- ["branch", "--no-color", "--no-column", "--remotes"],
- {
- timeoutMs: 10_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote ref lookup failed; falling back to an empty remote ref list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
-
- const remoteNamesResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteNames",
- input.cwd,
- ["remote"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote name lookup failed; falling back to an empty remote name list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
+ const byRecencyThenName = (
+ left: { readonly ref: VcsRef; readonly lastCommit: number },
+ right: { readonly ref: VcsRef; readonly lastCommit: number },
+ ) =>
+ left.lastCommit !== right.lastCommit
+ ? right.lastCommit - left.lastCommit
+ : left.ref.name.localeCompare(right.ref.name);
- const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
- yield* Effect.all(
- [
- executeGit(
- "GitVcsDriver.listRefs.defaultRef",
- input.cwd,
- ["symbolic-ref", "refs/remotes/origin/HEAD"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- executeGit(
- "GitVcsDriver.listRefs.worktreeList",
- input.cwd,
- ["worktree", "list", "--porcelain"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- remoteBranchResultEffect,
- remoteNamesResultEffect,
- branchRecencyPromise,
- ],
- { concurrency: "unbounded" },
- );
+ return {
+ localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ hasPrimaryRemote: remoteNames.includes("origin"),
+ } satisfies GitRefsSnapshot;
+ });
- const remoteNames =
- remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
- if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) {
- yield* Effect.logWarning(
- `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`,
- );
+ const listRefsEpochByCommonDir = new Map<string, number>();
+ let listRefsEpochSequence = 0;
+ const bumpListRefsEpoch = (gitCommonDir: string): number => {
+ const nextEpoch = ++listRefsEpochSequence;
+ listRefsEpochByCommonDir.delete(gitCommonDir);
+ listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch);
+ if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) {
+ const oldestKey = listRefsEpochByCommonDir.keys().next().value;
+ if (oldestKey !== undefined) {
+ listRefsEpochByCommonDir.delete(oldestKey);
}
- if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
- yield* Effect.logWarning(
- `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ }
+ return nextEpoch;
+ };
+ const listRefsSnapshotCache = yield* Cache.makeWith(
+ (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir),
+ {
+ capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero),
+ },
+ );
+ const listRefsRefreshSnapshotCache = yield* Cache.makeWith(
+ (gitCommonDir: string) =>
+ Effect.suspend(() => {
+ const epoch = bumpListRefsEpoch(gitCommonDir);
+ return Cache.get(
+ listRefsSnapshotCache,
+ new GitRefsSnapshotCacheKey({ gitCommonDir, epoch }),
);
- }
+ }),
+ {
+ capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : Duration.zero),
+ },
+ );
+ const resolveListRefsSnapshot = (gitCommonDir: string, refresh: boolean) => {
+ const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir);
+ if (refresh || currentEpoch === undefined) {
+ // The refresh cache owns the complete snapshot read, rather than only the
+ // epoch bump. Slow repositories therefore remain singleflight for the
+ // entire Git scan even when more refresh requests arrive after the
+ // coalescing TTL would otherwise have elapsed.
+ return Cache.get(listRefsRefreshSnapshotCache, gitCommonDir);
+ }
+ return Cache.get(
+ listRefsSnapshotCache,
+ new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }),
+ );
+ };
+ const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* (
+ cwd: string,
+ ) {
+ const repositoryPaths = yield* resolveRepositoryPaths(cwd);
+ if (repositoryPaths === null) return;
+ yield* Cache.invalidate(listRefsRefreshSnapshotCache, repositoryPaths.gitCommonDir);
+ bumpListRefsEpoch(repositoryPaths.gitCommonDir);
+ });
- const defaultBranch =
- defaultRef.exitCode === 0
- ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
- : null;
-
- const worktreeMap = new Map<string, string>();
- if (worktreeList.exitCode === 0) {
- let currentPath: string | null = null;
- for (const line of worktreeList.stdout.split("\n")) {
- if (line.startsWith("worktree ")) {
- const candidatePath = line.slice("worktree ".length);
- const exists = yield* fileSystem.stat(candidatePath).pipe(
- Effect.map(() => true),
- Effect.orElseSucceed(() => false),
- );
- currentPath = exists ? candidatePath : null;
- } else if (line.startsWith("branch refs/heads/") && currentPath) {
- worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
- } else if (line === "") {
- currentPath = null;
- }
- }
+ const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
+ function* (input) {
+ const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe(
+ Effect.catchTags({
+ GitCommandError: (error) =>
+ isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
+ }),
+ );
+ if (repositoryPaths === null) {
+ return {
+ refs: [],
+ isRepo: false,
+ hasPrimaryRemote: false,
+ nextCursor: null,
+ totalCount: 0,
+ };
}
- const localBranches = Arr.filterMap(localBranchResult.stdout.split("\n"), (line) => {
- const refName = parseBranchLine(line);
- return refName === null
- ? Result.failVoid
- : Result.succeed({
- name: refName.name,
- current: refName.current,
- isRemote: false,
- isDefault: refName.name === defaultBranch,
- worktreePath: worktreeMap.get(refName.name) ?? null,
- });
- }).toSorted((a, b) => {
- const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
- const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
- if (aPriority !== bPriority) return aPriority - bPriority;
-
- const aLastCommit = branchLastCommit.get(a.name) ?? 0;
- const bLastCommit = branchLastCommit.get(b.name) ?? 0;
- if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
- return a.name.localeCompare(b.name);
- });
-
- const remoteBranches =
- remoteBranchResult.exitCode === 0
- ? Arr.filterMap(remoteBranchResult.stdout.split("\n"), (line) => {
- const refName = parseBranchLine(line);
- if (refName === null) {
- return Result.failVoid;
- }
- const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames);
- const remoteBranch: {
- name: string;
- current: boolean;
- isRemote: boolean;
- remoteName?: string;
- isDefault: boolean;
- worktreePath: string | null;
- } = {
- name: refName.name,
- current: false,
- isRemote: true,
- // origin/HEAD's target is the repo default even when no local
- // copy of the default branch exists.
- isDefault:
- defaultBranch !== null &&
- parsedRemoteRef?.remoteName === "origin" &&
- parsedRemoteRef.branchName === defaultBranch,
- worktreePath: null,
- };
- if (parsedRemoteRef) {
- remoteBranch.remoteName = parsedRemoteRef.remoteName;
- }
- return Result.succeed(remoteBranch);
- }).toSorted((a, b) => {
- const aLastCommit = branchLastCommit.get(a.name) ?? 0;
- const bLastCommit = branchLastCommit.get(b.name) ?? 0;
- if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
- return a.name.localeCompare(b.name);
- })
- : [];
-
+ const snapshot = yield* resolveListRefsSnapshot(
+ repositoryPaths.gitCommonDir,
+ input.refresh === true,
+ );
+ const localBranches = snapshot.localBranches.map((ref) => ({
+ ...ref,
+ current:
+ repositoryPaths.worktreeRoot !== null &&
+ ref.worktreePath === repositoryPaths.worktreeRoot,
+ }));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.
packages/client-runtime/src/state/vcsRefInvalidation.ts Β· Earlier revision 5a352e1a Β· right 66
Original diff
@@ -0,0 +1,69 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
+
+import { safeErrorLogAttributes } from "../errors/safeLog.ts";
+import { EnvironmentCacheStore } from "../platform/persistence.ts";
+
+export interface VcsRefsInvalidationTarget {
+ readonly environmentId: EnvironmentId;
+}
+
+export interface CachedVcsRefsInvalidationTarget extends VcsRefsInvalidationTarget {
+ readonly cwd: string;
+}
+
+const revisionByEnvironment = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make(0).pipe(Atom.withLabel(`environment-data:vcs:list-refs-revision:${environmentId}`)),
+);
+const persistenceLock = PartitionedSemaphore.makeUnsafe<EnvironmentId>({ permits: 1 });
+
+export function vcsRefsRevisionAtom(target: VcsRefsInvalidationTarget) {
+ return revisionByEnvironment(target.environmentId);
+}
+
+export function invalidateVcsRefs(
+ registry: AtomRegistry.AtomRegistry,
+ target: VcsRefsInvalidationTarget,
+): void {
+ registry.update(vcsRefsRevisionAtom(target), (revision) => revision + 1);
+}
+
+export function withVcsRefsPersistenceLock<A, E, R>(
+ environmentId: EnvironmentId,
+ effect: Effect.Effect<A, E, R>,
+): Effect.Effect<A, E, R> {
+ return persistenceLock.withPermit(environmentId)(effect);
+}
+
+/**
+ * Clears persisted snapshots and then restarts live ref streams while holding
+ * the same lock used by generation-checked refresh writes. Clearing first
+ * prevents the restarted stream from rehydrating the invalidated snapshot. A
+ * refresh that wins the lock is cleared afterward; one that loses observes the
+ * new revision and cannot repersist its pre-mutation result.
+ */
+export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* (
+ registry: AtomRegistry.AtomRegistry,
+ target: CachedVcsRefsInvalidationTarget,
+) {
+ const cache = yield* EnvironmentCacheStore;
+ yield* withVcsRefsPersistenceLock(
+ target.environmentId,
+ Effect.gen(function* () {
+ yield* cache.clearVcsRefs(target.environmentId).pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("Could not remove invalidated cached Git refs.").pipe(
+ Effect.annotateLogs({
+ environmentId: target.environmentId,
+ cwd: target.cwd,
+ ...safeErrorLogAttributes(error),
+ }),
+ ),
+ ),
+ );
+ invalidateVcsRefs(registry, target);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.
apps/server/src/vcs/GitVcsDriverCore.ts Β· Earlier revision 25c0c7ab Β· right 246
Original diff
@@ -210,6 +228,37 @@ function paginateBranches(input: {
};
}
+function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {
+ const worktreePaths = new Map<string, string>();
+ let currentPath: string | null = null;
+ let currentBranch: string | null = null;
+ let currentPrunable = false;
+
+ const flush = () => {
+ if (currentPath !== null && currentBranch !== null && !currentPrunable) {
+ worktreePaths.set(currentBranch, currentPath);
+ }
+ currentPath = null;
+ currentBranch = null;
+ currentPrunable = false;
+ };
+
+ for (const line of stdout.split("\n")) {π‘ 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:
| 1 | In 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.
apps/web/src/state/paginatedBranches.ts Β· Earlier revision 33c01c57 Β· right 31β31
Original diff
@@ -0,0 +1,36 @@
+import type { VcsListRefsResult } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult } from "effect/unstable/reactivity";
+
+const DEFAULT_NEXT_PAGE_DISTANCE_PX = 96;
+
+export function isPaginatedBranchesNextPagePending<E>(
+ results: ReadonlyArray<AsyncResult.AsyncResult<VcsListRefsResult, E>>,
+): boolean {
+ const lastResult = results.at(-1);
+ return (
+ results.length > 1 &&
+ lastResult?.waiting === true &&
+ Option.isNone(AsyncResult.value(lastResult))
+ );
+}
+
+export function shouldLoadNextBranchPageAfterScroll({
+ previousScrollTop,
+ scrollTop,
+ scrollHeight,
+ clientHeight,
+ distanceThreshold = DEFAULT_NEXT_PAGE_DISTANCE_PX,
+}: {
+ previousScrollTop: number | null;
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
+ distanceThreshold?: number;
+}): boolean {
+ if (previousScrollTop === null || scrollTop <= previousScrollTop + 1) {apps/web/src/state/paginatedBranches.ts Β· right 31β31
π‘ 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.
| 1 | if (previousScrollTop === null || scrollTop <= previousScrollTop) { |
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In 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.
packages/client-runtime/src/state/vcs.ts Β· Earlier revision ff4df7fe Β· left 90
Original diff
@@ -82,15 +90,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
return refs;
});
- const cachedRefs = Stream.fromEffect(
- SubscriptionRef.get(supervisor.state).pipe(
- Effect.flatMap((connection) =>
- connection.phase === "connected"
- ? Effect.succeed(Option.none<VcsListRefsResult>())
- : Effect.succeed(cached),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/vcs.ts Β· Earlier revision 5a352e1a Β· right 297
Original diff
@@ -193,30 +266,35 @@ export function createVcsEnvironmentAtoms<R, E>(
tag: WS_METHODS.vcsCreateWorktree,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
removeWorktree: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:remove-worktree",
tag: WS_METHODS.vcsRemoveWorktree,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
createRef: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:create-ref",
tag: WS_METHODS.vcsCreateRef,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
switchRef: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:switch-ref",
tag: WS_METHODS.vcsSwitchRef,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
init: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:init",
tag: WS_METHODS.vcsInit,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,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.
apps/server/src/vcs/GitVcsDriverCore.ts Β· Earlier revision 25c0c7ab Β· right 2344
Original diff
@@ -1987,257 +2148,242 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
);
- const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
- function* (input) {
- const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
- Effect.orElseSucceed(() => new Map<string, number>()),
- );
- const localBranchResult = yield* executeGitWithStableDiagnostics(
- "GitVcsDriver.listRefs.branchNoColor",
- input.cwd,
- ["branch", "--no-color", "--no-column"],
- {
- timeoutMs: 10_000,
+ const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) {
+ const fetchCwd =
+ path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
+ const gitDirArgs = ["--git-dir", gitCommonDir] as const;
+ const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all(
+ [
+ executeGitWithStableDiagnostics(
+ "GitVcsDriver.listRefs.snapshotRefs",
+ fetchCwd,
+ [
+ ...gitDirArgs,
+ "for-each-ref",
+ "--format=%(refname)%09%(committerdate:unix)%09%(symref)",
+ "refs/heads",
+ "refs/remotes",
+ ],
+ {
+ timeoutMs: 30_000,
+ maxOutputBytes: 16 * 1024 * 1024,
+ fallbackErrorDetail: "Git ref snapshot enumeration failed.",
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.defaultRef",
+ fetchCwd,
+ [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"],
+ {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.worktreeList",
+ fetchCwd,
+ [...gitDirArgs, "worktree", "list", "--porcelain"],
+ {
+ timeoutMs: 30_000,
+ allowNonZeroExit: true,
+ maxOutputBytes: 16 * 1024 * 1024,
+ },
+ ),
+ executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
+ timeoutMs: 5_000,
allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- isMissingGitCwdError(error)
- ? Effect.succeed({
- exitCode: ChildProcessSpawner.ExitCode(128),
- stdout: "",
- stderr: "fatal: not a git repository",
- stdoutTruncated: false,
- stderrTruncated: false,
- })
- : Effect.fail(error),
}),
- );
+ ],
+ { concurrency: 2 },
+ );
- if (localBranchResult.exitCode !== 0) {
- const stderr = localBranchResult.stderr.trim();
- if (isNonRepositoryGitStderr(stderr)) {
- return {
- refs: [],
- isRepo: false,
- hasPrimaryRemote: false,
- nextCursor: null,
- totalCount: 0,
- };
- }
- return yield* new GitCommandError({
- ...gitCommandContext({
- operation: "GitVcsDriver.listRefs",
- cwd: input.cwd,
- args: ["branch", "--no-color", "--no-column"],
- }),
- detail: "Git branch listing failed.",
- exitCode: localBranchResult.exitCode,
- stdoutLength: localBranchResult.stdout.length,
- stderrLength: localBranchResult.stderr.length,
+ const remoteNames =
+ remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
+ if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
+ yield* Effect.logWarning(
+ `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ );
+ }
+ const defaultBranch =
+ defaultRefResult.exitCode === 0
+ ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
+ : null;
+ const parsedWorktreeEntries =
+ worktreeListResult.exitCode === 0
+ ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
+ ([branchName, worktreePath]) =>
+ [branchName, path.normalize(path.resolve(worktreePath))] as const,
+ )
+ : [];
+ const existingWorktreeEntries = yield* Effect.filter(
+ parsedWorktreeEntries,
+ ([, worktreePath]) =>
+ fileSystem.stat(worktreePath).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ ),
+ { concurrency: 16 },
+ );
+ const worktreeMap = new Map(existingWorktreeEntries);
+ const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+ const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+
+ for (const line of refsResult.stdout.split("\n")) {
+ if (line.length === 0) continue;
+ const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t");
+ if (!fullRefName || symbolicTarget) continue;
+ const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
+ const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0;
+
+ if (fullRefName.startsWith("refs/heads/")) {
+ const name = fullRefName.slice("refs/heads/".length);
+ localBranches.push({
+ ref: {
+ name,
+ current: false,
+ isRemote: false,
+ isDefault: name === defaultBranch,
+ worktreePath: worktreeMap.get(name) ?? null,
+ },
+ lastCommit,
});
+ continue;
}
+ if (!fullRefName.startsWith("refs/remotes/")) continue;
+
+ const name = fullRefName.slice("refs/remotes/".length);
+ const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames);
+ const remoteBranch: VcsRef = {
+ name,
+ current: false,
+ isRemote: true,
+ isDefault:
+ defaultBranch !== null &&
+ parsedRemoteRef?.remoteName === "origin" &&
+ parsedRemoteRef.branchName === defaultBranch,
+ worktreePath: null,
+ ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}),
+ };
+ remoteBranches.push({ ref: remoteBranch, lastCommit });
+ }
- const remoteBranchResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteBranches",
- input.cwd,
- ["branch", "--no-color", "--no-column", "--remotes"],
- {
- timeoutMs: 10_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote ref lookup failed; falling back to an empty remote ref list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
-
- const remoteNamesResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteNames",
- input.cwd,
- ["remote"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote name lookup failed; falling back to an empty remote name list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
+ const byRecencyThenName = (
+ left: { readonly ref: VcsRef; readonly lastCommit: number },
+ right: { readonly ref: VcsRef; readonly lastCommit: number },
+ ) =>
+ left.lastCommit !== right.lastCommit
+ ? right.lastCommit - left.lastCommit
+ : left.ref.name.localeCompare(right.ref.name);
- const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
- yield* Effect.all(
- [
- executeGit(
- "GitVcsDriver.listRefs.defaultRef",
- input.cwd,
- ["symbolic-ref", "refs/remotes/origin/HEAD"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- executeGit(
- "GitVcsDriver.listRefs.worktreeList",
- input.cwd,
- ["worktree", "list", "--porcelain"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- remoteBranchResultEffect,
- remoteNamesResultEffect,
- branchRecencyPromise,
- ],
- { concurrency: "unbounded" },
- );
+ return {
+ localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ hasPrimaryRemote: remoteNames.includes("origin"),
+ } satisfies GitRefsSnapshot;
+ });
- const remoteNames =
- remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
- if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) {
- yield* Effect.logWarning(
- `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`,
- );
+ const listRefsEpochByCommonDir = new Map<string, number>();
+ let listRefsEpochSequence = 0;
+ const bumpListRefsEpoch = (gitCommonDir: string): number => {
+ const nextEpoch = ++listRefsEpochSequence;
+ listRefsEpochByCommonDir.delete(gitCommonDir);
+ listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch);
+ if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) {
+ const oldestKey = listRefsEpochByCommonDir.keys().next().value;
+ if (oldestKey !== undefined) {
+ listRefsEpochByCommonDir.delete(oldestKey);
}
- if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
- yield* Effect.logWarning(
- `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ }
+ return nextEpoch;
+ };
+ const listRefsSnapshotCache = yield* Cache.makeWith(
+ (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir),
+ {
+ capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero),
+ },
+ );
+ const listRefsRefreshSnapshotCache = yield* Cache.makeWith(
+ (gitCommonDir: string) =>
+ Effect.suspend(() => {
+ const epoch = bumpListRefsEpoch(gitCommonDir);
+ return Cache.get(
+ listRefsSnapshotCache,
+ new GitRefsSnapshotCacheKey({ gitCommonDir, epoch }),
);
- }
+ }),
+ {
+ capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
+ timeToLive: (exit) =>
+ Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN,
+ },
+ );
+ const resolveListRefsSnapshot = (gitCommonDir: string, refresh: boolean) => {
+ const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir);
+ if (refresh || currentEpoch === undefined) {
+ // The refresh cache owns the complete snapshot read, rather than only the
+ // epoch bump. Slow repositories therefore remain singleflight for the
+ // entire Git scan even when more refresh requests arrive after the
+ // coalescing TTL would otherwise have elapsed.
+ return Cache.get(listRefsRefreshSnapshotCache, gitCommonDir);
+ }
+ return Cache.get(
+ listRefsSnapshotCache,
+ new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }),
+ );
+ };
+ const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* (
+ cwd: string,
+ ) {
+ const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd);
+ const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey);
+ if (repositoryPaths === null) return;
+ yield* Cache.invalidate(listRefsRefreshSnapshotCache, repositoryPaths.gitCommonDir);
+ bumpListRefsEpoch(repositoryPaths.gitCommonDir);
+ yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey);
+ yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey);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.ts Β· Earlier revision 4f3a6609 Β· right 52
Original diff
@@ -0,0 +1,53 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
+
+import { safeErrorLogAttributes } from "../errors/safeLog.ts";
+import { EnvironmentCacheStore } from "../platform/persistence.ts";
+
+export interface VcsRefsInvalidationTarget {
+ readonly environmentId: EnvironmentId;
+}
+
+export interface CachedVcsRefsInvalidationTarget extends VcsRefsInvalidationTarget {
+ readonly cwd: string;
+}
+
+const revisionByEnvironment = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make(0).pipe(Atom.withLabel(`environment-data:vcs:list-refs-revision:${environmentId}`)),
+);
+
+export function vcsRefsRevisionAtom(target: VcsRefsInvalidationTarget) {
+ return revisionByEnvironment(target.environmentId);
+}
+
+export function invalidateVcsRefs(
+ registry: AtomRegistry.AtomRegistry,
+ target: VcsRefsInvalidationTarget,
+): void {
+ registry.update(vcsRefsRevisionAtom(target), (revision) => revision + 1);
+}
+
+/**
+ * Removes the persisted snapshot before restarting live ref streams. This
+ * prevents a remount during the refresh window from replaying pre-mutation
+ * branch data.
+ */
+export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* (
+ registry: AtomRegistry.AtomRegistry,
+ target: CachedVcsRefsInvalidationTarget,
+) {
+ const cache = yield* EnvironmentCacheStore;
+ yield* cache.removeVcsRefs(target.environmentId, target.cwd).pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("Could not remove invalidated cached Git refs.").pipe(
+ Effect.annotateLogs({
+ environmentId: target.environmentId,
+ cwd: target.cwd,
+ ...safeErrorLogAttributes(error),
+ }),
+ ),
+ ),
+ );
+ invalidateVcsRefs(registry, target);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.
apps/server/src/vcs/GitVcsDriverCore.ts Β· Earlier revision f2449b51 Β· right 236
Original diff
@@ -210,6 +227,37 @@ function paginateBranches(input: {
};
}
+function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {
+ const worktreePaths = new Map<string, string>();
+ let currentPath: string | null = null;
+ let currentBranch: string | null = null;
+ let currentPrunable = false;
+
+ const flush = () => {π‘ 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:
| 1 | In 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.ts Β· Earlier revision 8fb87fd7 Β· right 2195
Original diff
@@ -1987,257 +2070,226 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
);
- const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
- function* (input) {
- const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
- Effect.orElseSucceed(() => new Map<string, number>()),
- );
- const localBranchResult = yield* executeGitWithStableDiagnostics(
- "GitVcsDriver.listRefs.branchNoColor",
- input.cwd,
- ["branch", "--no-color", "--no-column"],
- {
- timeoutMs: 10_000,
+ const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) {
+ const fetchCwd =
+ path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
+ const gitDirArgs = ["--git-dir", gitCommonDir] as const;
+ const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all(
+ [
+ executeGitWithStableDiagnostics(
+ "GitVcsDriver.listRefs.snapshotRefs",
+ fetchCwd,
+ [
+ ...gitDirArgs,
+ "for-each-ref",
+ "--format=%(refname)%09%(committerdate:unix)%09%(symref)",
+ "refs/heads",
+ "refs/remotes",
+ ],
+ {
+ timeoutMs: 30_000,
+ maxOutputBytes: 16 * 1024 * 1024,
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.defaultRef",
+ fetchCwd,
+ [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"],
+ {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.worktreeList",
+ fetchCwd,
+ [...gitDirArgs, "worktree", "list", "--porcelain"],
+ {
+ timeoutMs: 30_000,
+ allowNonZeroExit: true,
+ maxOutputBytes: 16 * 1024 * 1024,
+ },
+ ),
+ executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
+ timeoutMs: 5_000,
allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- isMissingGitCwdError(error)
- ? Effect.succeed({
- exitCode: ChildProcessSpawner.ExitCode(128),
- stdout: "",
- stderr: "fatal: not a git repository",
- stdoutTruncated: false,
- stderrTruncated: false,
- })
- : Effect.fail(error),
}),
- );
+ ],
+ { concurrency: 2 },
+ );
- if (localBranchResult.exitCode !== 0) {
- const stderr = localBranchResult.stderr.trim();
- if (isNonRepositoryGitStderr(stderr)) {
- return {
- refs: [],
- isRepo: false,
- hasPrimaryRemote: false,
- nextCursor: null,
- totalCount: 0,
- };
- }
- return yield* new GitCommandError({
- ...gitCommandContext({
- operation: "GitVcsDriver.listRefs",
- cwd: input.cwd,
- args: ["branch", "--no-color", "--no-column"],
- }),
- detail: "Git branch listing failed.",
- exitCode: localBranchResult.exitCode,
- stdoutLength: localBranchResult.stdout.length,
- stderrLength: localBranchResult.stderr.length,
+ const remoteNames =
+ remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
+ if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
+ yield* Effect.logWarning(
+ `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ );
+ }
+ const defaultBranch =
+ defaultRefResult.exitCode === 0
+ ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
+ : null;
+ const worktreeMap =
+ worktreeListResult.exitCode === 0
+ ? new Map(
+ [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
+ ([branchName, worktreePath]) =>
+ [branchName, path.normalize(path.resolve(worktreePath))] as const,
+ ),
+ )
+ : new Map<string, string>();
+ const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+ const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+
+ for (const line of refsResult.stdout.split("\n")) {
+ if (line.length === 0) continue;
+ const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t");
+ if (!fullRefName || symbolicTarget) continue;
+ const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
+ const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0;
+
+ if (fullRefName.startsWith("refs/heads/")) {
+ const name = fullRefName.slice("refs/heads/".length);
+ localBranches.push({
+ ref: {
+ name,
+ current: false,
+ isRemote: false,
+ isDefault: name === defaultBranch,
+ worktreePath: worktreeMap.get(name) ?? null,
+ },
+ lastCommit,
});
+ continue;
}
+ if (!fullRefName.startsWith("refs/remotes/")) continue;
+
+ const name = fullRefName.slice("refs/remotes/".length);
+ const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames);
+ const remoteBranch: VcsRef = {
+ name,
+ current: false,
+ isRemote: true,
+ isDefault:
+ defaultBranch !== null &&
+ parsedRemoteRef?.remoteName === "origin" &&
+ parsedRemoteRef.branchName === defaultBranch,
+ worktreePath: null,
+ ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}),
+ };
+ remoteBranches.push({ ref: remoteBranch, lastCommit });
+ }
- const remoteBranchResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteBranches",
- input.cwd,
- ["branch", "--no-color", "--no-column", "--remotes"],
- {
- timeoutMs: 10_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote ref lookup failed; falling back to an empty remote ref list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
-
- const remoteNamesResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteNames",
- input.cwd,
- ["remote"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote name lookup failed; falling back to an empty remote name list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
+ const byRecencyThenName = (
+ left: { readonly ref: VcsRef; readonly lastCommit: number },
+ right: { readonly ref: VcsRef; readonly lastCommit: number },
+ ) =>
+ left.lastCommit !== right.lastCommit
+ ? right.lastCommit - left.lastCommit
+ : left.ref.name.localeCompare(right.ref.name);
- const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
- yield* Effect.all(
- [
- executeGit(
- "GitVcsDriver.listRefs.defaultRef",
- input.cwd,
- ["symbolic-ref", "refs/remotes/origin/HEAD"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- executeGit(
- "GitVcsDriver.listRefs.worktreeList",
- input.cwd,
- ["worktree", "list", "--porcelain"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- remoteBranchResultEffect,
- remoteNamesResultEffect,
- branchRecencyPromise,
- ],
- { concurrency: "unbounded" },
- );
+ return {
+ localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ hasPrimaryRemote: remoteNames.includes("origin"),
+ } satisfies GitRefsSnapshot;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.
packages/client-runtime/src/state/vcsAction.ts Β· Earlier revision 561c0357 Β· right 497
Original diff
@@ -489,6 +490,11 @@ export function createVcsActionManager<R, E>(
}),
},
).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ invalidateVcsRefs(registry, target);
+ }),
+ ),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.ts Β· File comment
π‘ Medium
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.
| 1 | - onSuccess: invalidateRefs, |
| 2 | + onSettled: invalidateRefs, |
π Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In 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.
packages/client-runtime/src/state/vcsRefInvalidation.ts Β· Earlier revision b3f2a38d Β· right 39
Original diff
@@ -0,0 +1,83 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
+
+import { safeErrorLogAttributes } from "../errors/safeLog.ts";
+import { EnvironmentCacheStore } from "../platform/persistence.ts";
+
+export interface VcsRefsInvalidationTarget {
+ readonly environmentId: EnvironmentId;
+}
+
+export interface CachedVcsRefsInvalidationTarget extends VcsRefsInvalidationTarget {
+ readonly cwd: string;
+}
+
+export interface VcsRefsCacheState {
+ readonly revision: number;
+ readonly persistedCacheReadable: boolean;
+}
+
+const stateByEnvironment = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make<VcsRefsCacheState>({
+ revision: 0,
+ persistedCacheReadable: true,
+ }).pipe(Atom.withLabel(`environment-data:vcs:list-refs-state:${environmentId}`)),
+);
+const persistenceLock = PartitionedSemaphore.makeUnsafe<EnvironmentId>({ permits: 1 });
+
+export function vcsRefsCacheStateAtom(target: VcsRefsInvalidationTarget) {
+ return stateByEnvironment(target.environmentId);
+}
+
+export function invalidateVcsRefs(
+ registry: AtomRegistry.AtomRegistry,
+ target: VcsRefsInvalidationTarget,
+ persistedCacheReadable?: boolean,
+): void {
+ registry.update(vcsRefsCacheStateAtom(target), (state) => ({π‘ 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:
| 1 | In 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.ts Β· Earlier revision 70b295c2 Β· right 291
Original diff
@@ -181,6 +288,7 @@ export function createVcsEnvironmentAtoms<R, E>(
tag: WS_METHODS.vcsPull,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSettled: invalidateRefs,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.
packages/client-runtime/src/state/vcs.ts Β· Earlier revision 2a9d07ea Β· right 185
Original diff
@@ -160,6 +171,18 @@ export function createVcsEnvironmentAtoms<R, E>(
readonly environmentId: EnvironmentId;
readonly input: VcsListRefsInput;
}) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input));
+ const invalidateRefs = (
+ target: {
+ readonly environmentId: EnvironmentId;
+ readonly input: { readonly cwd: string };
+ },
+ registry: AtomRegistry.AtomRegistry,
+ ) => {
+ refsInvalidation.invalidate(registry, {
+ environmentId: target.environmentId,
+ cwd: target.input.cwd,
+ });
+ };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.ts Β· Earlier revision 45ac080c Β· left 124
Original diff
@@ -107,31 +106,19 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
Stream.switchMap((generation) =>
generation === null
? Stream.empty
- : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe(
- Stream.mapEffect(
- () =>
- refresh().pipe(
- Effect.map(Option.some),
- Effect.catch((error) =>
- Effect.logWarning("Could not refresh Git refs.").pipe(
- Effect.annotateLogs({
- environmentId,
- cwd: input.cwd,
- ...safeErrorLogAttributes(error),
- }),
- Effect.as(Option.none<VcsListRefsResult>()),
- ),
- ),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/vcsRefInvalidation.ts Β· Earlier revision 4ba5ea3a Β· right 53β55
Original diff
@@ -0,0 +1,68 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
+
+import { safeErrorLogAttributes } from "../errors/safeLog.ts";
+import { EnvironmentCacheStore } from "../platform/persistence.ts";
+
+export interface VcsRefsInvalidationTarget {
+ readonly environmentId: EnvironmentId;
+}
+
+export interface CachedVcsRefsInvalidationTarget extends VcsRefsInvalidationTarget {
+ readonly cwd: string;
+}
+
+const revisionByEnvironment = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make(0).pipe(Atom.withLabel(`environment-data:vcs:list-refs-revision:${environmentId}`)),
+);
+const persistenceLock = PartitionedSemaphore.makeUnsafe<EnvironmentId>({ permits: 1 });
+
+export function vcsRefsRevisionAtom(target: VcsRefsInvalidationTarget) {
+ return revisionByEnvironment(target.environmentId);
+}
+
+export function invalidateVcsRefs(
+ registry: AtomRegistry.AtomRegistry,
+ target: VcsRefsInvalidationTarget,
+): void {
+ registry.update(vcsRefsRevisionAtom(target), (revision) => revision + 1);
+}
+
+export function withVcsRefsPersistenceLock<A, E, R>(
+ environmentId: EnvironmentId,
+ effect: Effect.Effect<A, E, R>,
+): Effect.Effect<A, E, R> {
+ return persistenceLock.withPermit(environmentId)(effect);
+}
+
+/**
+ * Restarts live ref streams and clears persisted snapshots while holding the
+ * same lock used by generation-checked refresh writes. A refresh that wins the
+ * lock is cleared afterward; one that loses observes the new revision and
+ * cannot repersist its pre-mutation result.
+ */
+export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* (
+ registry: AtomRegistry.AtomRegistry,
+ target: CachedVcsRefsInvalidationTarget,
+) {
+ const cache = yield* EnvironmentCacheStore;
+ yield* withVcsRefsPersistenceLock(
+ target.environmentId,
+ Effect.gen(function* () {
+ invalidateVcsRefs(registry, target);
+ yield* cache.clearVcsRefs(target.environmentId).pipe(packages/client-runtime/src/state/vcsRefInvalidation.ts Β· right 53β55
π‘ 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.
| 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:
| 1 | In 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.ts Β· Earlier revision f2449b51 Β· right 2348
Original diff
@@ -1987,257 +2129,233 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
);
- const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
- function* (input) {
- const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
- Effect.orElseSucceed(() => new Map<string, number>()),
- );
- const localBranchResult = yield* executeGitWithStableDiagnostics(
- "GitVcsDriver.listRefs.branchNoColor",
- input.cwd,
- ["branch", "--no-color", "--no-column"],
- {
- timeoutMs: 10_000,
+ const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) {
+ const fetchCwd =
+ path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
+ const gitDirArgs = ["--git-dir", gitCommonDir] as const;
+ const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all(
+ [
+ executeGitWithStableDiagnostics(
+ "GitVcsDriver.listRefs.snapshotRefs",
+ fetchCwd,
+ [
+ ...gitDirArgs,
+ "for-each-ref",
+ "--format=%(refname)%09%(committerdate:unix)%09%(symref)",
+ "refs/heads",
+ "refs/remotes",
+ ],
+ {
+ timeoutMs: 30_000,
+ maxOutputBytes: 16 * 1024 * 1024,
+ fallbackErrorDetail: "Git ref snapshot enumeration failed.",
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.defaultRef",
+ fetchCwd,
+ [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"],
+ {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ },
+ ),
+ executeGit(
+ "GitVcsDriver.listRefs.worktreeList",
+ fetchCwd,
+ [...gitDirArgs, "worktree", "list", "--porcelain"],
+ {
+ timeoutMs: 30_000,
+ allowNonZeroExit: true,
+ maxOutputBytes: 16 * 1024 * 1024,
+ },
+ ),
+ executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
+ timeoutMs: 5_000,
allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- isMissingGitCwdError(error)
- ? Effect.succeed({
- exitCode: ChildProcessSpawner.ExitCode(128),
- stdout: "",
- stderr: "fatal: not a git repository",
- stdoutTruncated: false,
- stderrTruncated: false,
- })
- : Effect.fail(error),
}),
- );
+ ],
+ { concurrency: 2 },
+ );
- if (localBranchResult.exitCode !== 0) {
- const stderr = localBranchResult.stderr.trim();
- if (isNonRepositoryGitStderr(stderr)) {
- return {
- refs: [],
- isRepo: false,
- hasPrimaryRemote: false,
- nextCursor: null,
- totalCount: 0,
- };
- }
- return yield* new GitCommandError({
- ...gitCommandContext({
- operation: "GitVcsDriver.listRefs",
- cwd: input.cwd,
- args: ["branch", "--no-color", "--no-column"],
- }),
- detail: "Git branch listing failed.",
- exitCode: localBranchResult.exitCode,
- stdoutLength: localBranchResult.stdout.length,
- stderrLength: localBranchResult.stderr.length,
+ const remoteNames =
+ remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
+ if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
+ yield* Effect.logWarning(
+ `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ );
+ }
+ const defaultBranch =
+ defaultRefResult.exitCode === 0
+ ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
+ : null;
+ const worktreeMap =
+ worktreeListResult.exitCode === 0
+ ? new Map(
+ [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
+ ([branchName, worktreePath]) =>
+ [branchName, path.normalize(path.resolve(worktreePath))] as const,
+ ),
+ )
+ : new Map<string, string>();
+ const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+ const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
+
+ for (const line of refsResult.stdout.split("\n")) {
+ if (line.length === 0) continue;
+ const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t");
+ if (!fullRefName || symbolicTarget) continue;
+ const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
+ const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0;
+
+ if (fullRefName.startsWith("refs/heads/")) {
+ const name = fullRefName.slice("refs/heads/".length);
+ localBranches.push({
+ ref: {
+ name,
+ current: false,
+ isRemote: false,
+ isDefault: name === defaultBranch,
+ worktreePath: worktreeMap.get(name) ?? null,
+ },
+ lastCommit,
});
+ continue;
}
+ if (!fullRefName.startsWith("refs/remotes/")) continue;
+
+ const name = fullRefName.slice("refs/remotes/".length);
+ const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames);
+ const remoteBranch: VcsRef = {
+ name,
+ current: false,
+ isRemote: true,
+ isDefault:
+ defaultBranch !== null &&
+ parsedRemoteRef?.remoteName === "origin" &&
+ parsedRemoteRef.branchName === defaultBranch,
+ worktreePath: null,
+ ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}),
+ };
+ remoteBranches.push({ ref: remoteBranch, lastCommit });
+ }
- const remoteBranchResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteBranches",
- input.cwd,
- ["branch", "--no-color", "--no-column", "--remotes"],
- {
- timeoutMs: 10_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote ref lookup failed; falling back to an empty remote ref list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
-
- const remoteNamesResultEffect = executeGit(
- "GitVcsDriver.listRefs.remoteNames",
- input.cwd,
- ["remote"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ).pipe(
- Effect.catchTags({
- GitCommandError: (error) =>
- Effect.logWarning(
- "Git remote name lookup failed; falling back to an empty remote name list.",
- {
- operation: error.operation,
- command: error.command,
- cwd: error.cwd,
- detail: error.detail,
- cause: error,
- },
- ).pipe(
- Effect.as({
- exitCode: ChildProcessSpawner.ExitCode(1),
- stdout: "",
- stderr: "",
- stdoutTruncated: false,
- stderrTruncated: false,
- } satisfies GitVcsDriver.ExecuteGitResult),
- ),
- }),
- );
+ const byRecencyThenName = (
+ left: { readonly ref: VcsRef; readonly lastCommit: number },
+ right: { readonly ref: VcsRef; readonly lastCommit: number },
+ ) =>
+ left.lastCommit !== right.lastCommit
+ ? right.lastCommit - left.lastCommit
+ : left.ref.name.localeCompare(right.ref.name);
- const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
- yield* Effect.all(
- [
- executeGit(
- "GitVcsDriver.listRefs.defaultRef",
- input.cwd,
- ["symbolic-ref", "refs/remotes/origin/HEAD"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- executeGit(
- "GitVcsDriver.listRefs.worktreeList",
- input.cwd,
- ["worktree", "list", "--porcelain"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
- remoteBranchResultEffect,
- remoteNamesResultEffect,
- branchRecencyPromise,
- ],
- { concurrency: "unbounded" },
- );
+ return {
+ localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref),
+ hasPrimaryRemote: remoteNames.includes("origin"),
+ } satisfies GitRefsSnapshot;
+ });
- const remoteNames =
- remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
- if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) {
- yield* Effect.logWarning(
- `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`,
- );
+ const listRefsEpochByCommonDir = new Map<string, number>();
+ let listRefsEpochSequence = 0;
+ const bumpListRefsEpoch = (gitCommonDir: string): number => {
+ const nextEpoch = ++listRefsEpochSequence;
+ listRefsEpochByCommonDir.delete(gitCommonDir);
+ listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch);
+ if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) {
+ const oldestKey = listRefsEpochByCommonDir.keys().next().value;
+ if (oldestKey !== undefined) {
+ listRefsEpochByCommonDir.delete(oldestKey);
}
- if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
- yield* Effect.logWarning(
- `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+ }
+ return nextEpoch;
+ };
+ const listRefsSnapshotCache = yield* Cache.makeWith(
+ (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir),
+ {
+ capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero),
+ },
+ );
+ const listRefsRefreshSnapshotCache = yield* Cache.makeWith(
+ (gitCommonDir: string) =>
+ Effect.suspend(() => {
+ const epoch = bumpListRefsEpoch(gitCommonDir);
+ return Cache.get(
+ listRefsSnapshotCache,
+ new GitRefsSnapshotCacheKey({ gitCommonDir, epoch }),
);
- }
+ }),
+ {
+ capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY,
+ timeToLive: (exit) =>
+ Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN,
+ },
+ );
+ const resolveListRefsSnapshot = (gitCommonDir: string, refresh: boolean) => {
+ const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir);
+ if (refresh || currentEpoch === undefined) {
+ // The refresh cache owns the complete snapshot read, rather than only the
+ // epoch bump. Slow repositories therefore remain singleflight for the
+ // entire Git scan even when more refresh requests arrive after the
+ // coalescing TTL would otherwise have elapsed.
+ return Cache.get(listRefsRefreshSnapshotCache, gitCommonDir);
+ }
+ return Cache.get(
+ listRefsSnapshotCache,
+ new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }),
+ );
+ };
+ const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* (
+ cwd: string,
+ ) {
+ const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd);
+ const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey);
+ if (repositoryPaths === null) return;
+ yield* Cache.invalidate(listRefsRefreshSnapshotCache, repositoryPaths.gitCommonDir);
+ bumpListRefsEpoch(repositoryPaths.gitCommonDir);
+ yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey);
+ });
- const defaultBranch =
- defaultRef.exitCode === 0
- ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
- : null;
-
- const worktreeMap = new Map<string, string>();
- if (worktreeList.exitCode === 0) {
- let currentPath: string | null = null;
- for (const line of worktreeList.stdout.split("\n")) {
- if (line.startsWith("worktree ")) {
- const candidatePath = line.slice("worktree ".length);
- const exists = yield* fileSystem.stat(candidatePath).pipe(
- Effect.map(() => true),
- Effect.orElseSucceed(() => false),
- );
- currentPath = exists ? candidatePath : null;
- } else if (line.startsWith("branch refs/heads/") && currentPath) {
- worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
- } else if (line === "") {
- currentPath = null;
- }
- }
+ const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
+ function* (input) {
+ const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe(
+ Effect.catchTags({
+ GitCommandError: (error) =>
+ isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
+ }),
+ );
+ if (repositoryPaths === null) {
+ return {
+ refs: [],
+ isRepo: false,
+ hasPrimaryRemote: false,
+ nextCursor: null,
+ totalCount: 0,
+ };
}
- const localBranches = Arr.filterMap(localBranchResult.stdout.split("\n"), (line) => {
- const refName = parseBranchLine(line);
- return refName === null
- ? Result.failVoid
- : Result.succeed({
- name: refName.name,
- current: refName.current,
- isRemote: false,
- isDefault: refName.name === defaultBranch,
- worktreePath: worktreeMap.get(refName.name) ?? null,
- });
- }).toSorted((a, b) => {
- const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
- const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
- if (aPriority !== bPriority) return aPriority - bPriority;
-
- const aLastCommit = branchLastCommit.get(a.name) ?? 0;
- const bLastCommit = branchLastCommit.get(b.name) ?? 0;
- if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
- return a.name.localeCompare(b.name);
- });
-
- const remoteBranches =
- remoteBranchResult.exitCode === 0
- ? Arr.filterMap(remoteBranchResult.stdout.split("\n"), (line) => {
- const refName = parseBranchLine(line);
- if (refName === null) {
- return Result.failVoid;
- }
- const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames);
- const remoteBranch: {
- name: string;
- current: boolean;
- isRemote: boolean;
- remoteName?: string;
- isDefault: boolean;
- worktreePath: string | null;
- } = {
- name: refName.name,
- current: false,
- isRemote: true,
- // origin/HEAD's target is the repo default even when no local
- // copy of the default branch exists.
- isDefault:
- defaultBranch !== null &&
- parsedRemoteRef?.remoteName === "origin" &&
- parsedRemoteRef.branchName === defaultBranch,
- worktreePath: null,
- };
- if (parsedRemoteRef) {
- remoteBranch.remoteName = parsedRemoteRef.remoteName;
- }
- return Result.succeed(remoteBranch);
- }).toSorted((a, b) => {
- const aLastCommit = branchLastCommit.get(a.name) ?? 0;
- const bLastCommit = branchLastCommit.get(b.name) ?? 0;
- if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
- return a.name.localeCompare(b.name);
- })
- : [];
-
+ const snapshot = yield* resolveListRefsSnapshot(
+ repositoryPaths.gitCommonDir,
+ input.refresh === true,
+ );
+ const hasCurrentWorktreeBranch =
+ repositoryPaths.worktreeRoot !== null &&
+ snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot);
+ const localBranches = snapshot.localBranches.map((ref) => ({
+ ...ref,
+ current: hasCurrentWorktreeBranch
+ ? ref.worktreePath === repositoryPaths.worktreeRoot
+ : ref.name === repositoryPaths.currentBranch,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.
packages/client-runtime/src/state/vcs.ts Β· Earlier revision 4f3a6609 Β· right 154
Original diff
@@ -160,6 +151,14 @@ export function createVcsEnvironmentAtoms<R, E>(
readonly environmentId: EnvironmentId;
readonly input: VcsListRefsInput;
}) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input));
+ const invalidateRefs = (π‘ 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
onSuccesshook invalidates only the cache entry fortarget.input.cwd. Git ref mutations affect every worktree sharing the repository, while persistedvcs-refsentries are keyed by each worktree'scwd; 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
invalidateCachedVcsRefsremoves only the persisted entry fortarget.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:
| 1 | In 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 | |
| 5 | Also 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/vcs.ts Β· Earlier revision dfc1459e Β· right 243
Original diff
@@ -193,30 +212,35 @@ export function createVcsEnvironmentAtoms<R, E>(
tag: WS_METHODS.vcsCreateWorktree,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
removeWorktree: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:remove-worktree",
tag: WS_METHODS.vcsRemoveWorktree,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
createRef: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:create-ref",
tag: WS_METHODS.vcsCreateRef,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
switchRef: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:switch-ref",
tag: WS_METHODS.vcsSwitchRef,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,
}),
init: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:init",
tag: WS_METHODS.vcsInit,
scheduler: vcsCommandScheduler,
concurrency: vcsCommandConcurrency,
+ onSuccess: invalidateRefs,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.
apps/server/src/vcs/GitVcsDriverCore.ts Β· Earlier revision 501a7381 Β· right 1109
Original diff
@@ -944,28 +990,138 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
).pipe(Effect.asVoid);
};
+ const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* (
+ cwd: string,
+ ) {
+ const commonDirResult = yield* executeGitWithStableDiagnostics(
+ "GitVcsDriver.resolveRepositoryPaths.commonDir",
+ cwd,
+ ["rev-parse", "--git-common-dir"],
+ {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ },
+ );
+ if (commonDirResult.exitCode !== 0) {
+ const stderr = commonDirResult.stderr.trim();
+ if (isNonRepositoryGitStderr(stderr)) {
+ return null;
+ }
+ return yield* new GitCommandError({
+ ...gitCommandContext({
+ operation: "GitVcsDriver.resolveRepositoryPaths.commonDir",
+ cwd,
+ args: ["rev-parse", "--git-common-dir"],
+ }),
+ detail: "Failed to resolve the Git common directory.",
+ exitCode: commonDirResult.exitCode,
+ stdoutLength: commonDirResult.stdout.length,
+ stderrLength: commonDirResult.stderr.length,
+ });
+ }
+
+ const commonDirOutput = commonDirResult.stdout.trim();
+ const resolvedGitCommonDir = path.isAbsolute(commonDirOutput)
+ ? path.normalize(commonDirOutput)
+ : path.resolve(cwd, commonDirOutput);
+ const gitCommonDir = yield* fileSystem
+ .realPath(resolvedGitCommonDir)
+ .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir));
+ const worktreeRootResult = yield* executeGit(
+ "GitVcsDriver.resolveRepositoryPaths.worktreeRoot",
+ cwd,
+ ["rev-parse", "--show-toplevel"],
+ {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ },
+ );
+ const worktreeRootOutput = worktreeRootResult.stdout.trim();
+ const worktreeRoot =
+ worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0
+ ? path.normalize(
+ path.isAbsolute(worktreeRootOutput)
+ ? worktreeRootOutput
+ : path.resolve(cwd, worktreeRootOutput),
+ )
+ : null;
+
+ return {
+ gitCommonDir,
+ worktreeRoot,
+ } satisfies GitRepositoryPaths;
+ });
+
+ const repositoryPathsCache = yield* Cache.makeWith(
+ (cwd: string) => resolveRepositoryPathsUncached(cwd),
+ {
+ capacity: REPOSITORY_PATHS_CACHE_CAPACITY,
+ timeToLive: Exit.match({
+ onSuccess: (repositoryPaths) =>
+ repositoryPaths === null ? NON_REPOSITORY_PATHS_CACHE_TTL : REPOSITORY_PATHS_CACHE_TTL,
+ onFailure: () => Duration.zero,
+ }),
+ },
+ );
+ const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd));
+ const resolveRepositoryPaths = (cwd: string) =>
+ Cache.get(repositoryPathsCache, normalizeRepositoryPathsCacheKey(cwd));
+
const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
- const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [
- "rev-parse",
- "--git-common-dir",
- ]).pipe(Effect.map((stdout) => stdout.trim()));
- return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
+ const repositoryPaths = yield* resolveRepositoryPaths(cwd);
+ if (repositoryPaths !== null) {
+ return repositoryPaths.gitCommonDir;
+ }
+ return yield* new GitCommandError({
+ ...gitCommandContext({
+ operation: "GitVcsDriver.resolveGitCommonDir",
+ cwd,
+ args: ["rev-parse", "--git-common-dir"],
+ }),
+ detail: "Cannot resolve a Git common directory outside a repository.",
+ });
});
+ const statusRemoteRefreshFailureCounts = new Map<string, number>();
+ const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) =>
+ `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`;
+ const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => {
+ const key = statusRemoteRefreshFailureKey(cacheKey);
+ const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1;
+ statusRemoteRefreshFailureCounts.delete(key);
+ statusRemoteRefreshFailureCounts.set(key, nextCount);
+ if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) {
+ const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value;
+ if (oldestKey !== undefined) {
+ statusRemoteRefreshFailureCounts.delete(oldestKey);
+ }
+ }
+ };
+ const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => {
+ statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey));
+ };
const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* (
cacheKey: StatusRemoteRefreshCacheKey,
) {
- yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName);
- return true as const;
+ return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(
+ Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),
+ Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),
+ Effect.as(true as const),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.
packages/client-runtime/src/state/vcs.ts Β· Earlier revision 501a7381 Β· left 92
Original diff
@@ -82,15 +82,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
return refs;
});
- const cachedRefs = Stream.fromEffect(
- SubscriptionRef.get(supervisor.state).pipe(
- Effect.flatMap((connection) =>
- connection.phase === "connected"
- ? Effect.succeed(Option.none<VcsListRefsResult>())
- : Effect.succeed(cached),
- ),
- ),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.
packages/client-runtime/src/state/vcs.ts Β· Earlier revision 561c0357 Β· right 93
Original diff
@@ -82,15 +90,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange
return refs;
});
- const cachedRefs = Stream.fromEffect(
- SubscriptionRef.get(supervisor.state).pipe(
- Effect.flatMap((connection) =>
- connection.phase === "connected"
- ? Effect.succeed(Option.none<VcsListRefsResult>())
- : Effect.succeed(cached),
- ),
- ),
- ).pipe(
+ const cachedRefs = Stream.fromEffect(Effect.succeed(cached)).pipe(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.