authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-14 11:41:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-14 11:41:12-07:00
log1db585878ebc85927ddfe8a2bf50764e8e56ae4e
tree939e9a773f952b5c52de1e931513ca98b305feac
parent56e57de9478423becb33723a460eecf508fe1d5d
signature Signed by PGP key B5690EEEBB952194

Guard VCS status updates against stale targets (#3084)


9 files changed, 125 insertions(+), 18 deletions(-)

apps/web/src/components/ChatView.browser.tsx+1
......@@ -100,6 +100,7 @@ vi.mock("../lib/vcsStatusState", () => {
100100 };
101101
102102 return {
103 getVcsStatusDataForTarget: (state: typeof status) => state.data,
103104 getVcsStatusSnapshot: () => status,
104105 useVcsStatus: () => status,
105106 useVcsStatuses: () => new Map(),
apps/web/src/components/GitActionsControl.browser.tsx+1
......@@ -94,6 +94,7 @@ vi.mock("~/lib/sourceControlActions", () => ({
9494}));
9595
9696vi.mock("~/lib/vcsStatusState", () => ({
97 getVcsStatusDataForTarget: (state: { data: unknown }) => state.data,
9798 refreshVcsStatus: refreshVcsStatusSpy,
9899 resetVcsStatusStateForTests: () => undefined,
99100 useVcsStatus: vi.fn(() => ({
apps/web/src/components/GitActionsControl.logic.test.ts+9
......@@ -1100,6 +1100,15 @@ describe("resolveLiveThreadBranchUpdate", () => {
11001100
11011101 assert.equal(update, null);
11021102 });
1103
1104 it("allows a temporary worktree ref to reconcile to a semantic branch", () => {
1105 const update = resolveLiveThreadBranchUpdate({
1106 threadBranch: "t3code/a9628676",
1107 gitStatus: status({ refName: "feature/diff-panel-toggle" }),
1108 });
1109
1110 assert.deepEqual(update, { branch: "feature/diff-panel-toggle" });
1111 });
11031112});
11041113
11051114describe("resolveAutoFeatureBranchName", () => {
apps/web/src/components/GitActionsControl.tsx+8-5
......@@ -71,7 +71,7 @@ import {
7171 useVcsInitAction,
7272 useVcsPullAction,
7373} from "~/lib/sourceControlActions";
74import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState";
74import { getVcsStatusDataForTarget, refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState";
7575import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";
7676import { newCommandId, randomUUID } from "~/lib/utils";
7777import { resolvePathLinkTarget } from "~/terminal-links";
......@@ -1058,10 +1058,13 @@ export default function GitActionsControl({
10581058 [persistThreadBranchSync],
10591059 );
10601060
1061 const { data: gitStatus, error: gitStatusError } = useVcsStatus({
1062 environmentId: activeEnvironmentId,
1063 cwd: gitCwd,
1064 });
1061 const vcsStatusTarget = useMemo(
1062 () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }),
1063 [activeEnvironmentId, gitCwd],
1064 );
1065 const gitStatusQuery = useVcsStatus(vcsStatusTarget);
1066 const { error: gitStatusError } = gitStatusQuery;
1067 const gitStatus = getVcsStatusDataForTarget(gitStatusQuery, vcsStatusTarget);
10651068 const sourceControlPresentation = useMemo(
10661069 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),
10671070 [gitStatus?.sourceControlProvider],
apps/web/src/components/KeybindingsToast.browser.tsx+1
......@@ -67,6 +67,7 @@ vi.mock("../lib/vcsStatusState", () => {
6767 };
6868
6969 return {
70 getVcsStatusDataForTarget: (state: typeof status) => state.data,
7071 getVcsStatusSnapshot: () => status,
7172 useVcsStatus: () => status,
7273 useVcsStatuses: () => new Map(),
apps/web/src/lib/vcsStatusState.ts+2
......@@ -6,6 +6,7 @@ import {
66 EMPTY_VCS_STATUS_ATOM,
77 EMPTY_VCS_STATUS_STATE,
88 createVcsStatusManager,
9 getVcsStatusDataForTarget,
910 getVcsStatusTargetKey,
1011 vcsStatusStateAtom,
1112} from "@t3tools/client-runtime";
......@@ -19,6 +20,7 @@ import {
1920import { appAtomRegistry } from "../rpc/atomRegistry";
2021
2122export type { VcsStatusState, VcsStatusTarget };
23export { getVcsStatusDataForTarget };
2224
2325const manager = createVcsStatusManager({
2426 getRegistry: () => appAtomRegistry,
packages/client-runtime/src/gitActions.test.ts created+43
......@@ -0,0 +1,43 @@
1import type { VcsStatusResult } from "@t3tools/contracts";
2import { assert, describe, it } from "vite-plus/test";
3
4import { resolveLiveThreadBranchUpdate } from "./gitActions.js";
5
6function status(refName: string): VcsStatusResult {
7 return {
8 isRepo: true,
9 hasPrimaryRemote: true,
10 isDefaultRef: refName === "main",
11 refName,
12 hasWorkingTreeChanges: false,
13 workingTree: {
14 files: [],
15 insertions: 0,
16 deletions: 0,
17 },
18 hasUpstream: true,
19 aheadCount: 0,
20 behindCount: 0,
21 pr: null,
22 };
23}
24
25describe("resolveLiveThreadBranchUpdate", () => {
26 it("allows a temporary worktree ref to reconcile to a semantic branch", () => {
27 const update = resolveLiveThreadBranchUpdate({
28 threadBranch: "t3code/a9628676",
29 gitStatus: status("feature/diff-panel-toggle"),
30 });
31
32 assert.deepEqual(update, { branch: "feature/diff-panel-toggle" });
33 });
34
35 it("still reconciles ordinary semantic branch changes", () => {
36 const update = resolveLiveThreadBranchUpdate({
37 threadBranch: "feature/old",
38 gitStatus: status("feature/new"),
39 });
40
41 assert.deepEqual(update, { branch: "feature/new" });
42 });
43});
packages/client-runtime/src/vcsStatusState.test.ts+37-4
......@@ -2,7 +2,11 @@ import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts";
22import { AtomRegistry } from "effect/unstable/reactivity";
33import { afterEach, describe, expect, it, vi } from "vite-plus/test";
44
5import { type VcsStatusClient, createVcsStatusManager } from "./vcsStatusState.ts";
5import {
6 type VcsStatusClient,
7 createVcsStatusManager,
8 getVcsStatusDataForTarget,
9} from "./vcsStatusState.ts";
610
711/* ─── Test helpers ──────────────────────────────────────────────────── */
812
......@@ -57,12 +61,24 @@ function createMockClient(): {
5761 };
5862}
5963
60const PENDING = { data: null, error: null, cause: null, isPending: true };
61const EMPTY = { data: null, error: null, cause: null, isPending: false };
62
6364const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
6465const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const;
6566const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const;
67const TARGET_KEY = "env-local:/repo";
68const PENDING = {
69 targetKey: TARGET_KEY,
70 data: null,
71 error: null,
72 cause: null,
73 isPending: true,
74};
75const EMPTY = {
76 targetKey: null,
77 data: null,
78 error: null,
79 cause: null,
80 isPending: false,
81};
6682
6783/* ─── Tests ─────────────────────────────────────────────────────────── */
6884
......@@ -99,6 +115,7 @@ describe("createVcsStatusManager", () => {
99115
100116 emit(BASE_STATUS);
101117 expect(manager.getSnapshot(TARGET)).toEqual({
118 targetKey: TARGET_KEY,
102119 data: BASE_STATUS,
103120 error: null,
104121 cause: null,
......@@ -130,6 +147,7 @@ describe("createVcsStatusManager", () => {
130147
131148 // Snapshot still reflects stream data, not the refresh response
132149 expect(manager.getSnapshot(TARGET)).toEqual({
150 targetKey: TARGET_KEY,
133151 data: BASE_STATUS,
134152 error: null,
135153 cause: null,
......@@ -160,6 +178,19 @@ describe("createVcsStatusManager", () => {
160178 releaseRemote();
161179 });
162180
181 it("rejects status data from a previous cwd during target transitions", () => {
182 const staleState = {
183 targetKey: TARGET_KEY,
184 data: BASE_STATUS,
185 error: null,
186 cause: null,
187 isPending: false,
188 };
189
190 expect(getVcsStatusDataForTarget(staleState, FRESH_TARGET)).toBeNull();
191 expect(getVcsStatusDataForTarget(staleState, TARGET)).toBe(BASE_STATUS);
192 });
193
163194 it("returns null from refresh when no client is available", async () => {
164195 const manager = createVcsStatusManager({
165196 getRegistry: () => atomRegistry,
......@@ -204,6 +235,7 @@ describe("createVcsStatusManager", () => {
204235
205236 mock.emit(BASE_STATUS);
206237 expect(manager.getSnapshot(TARGET)).toEqual({
238 targetKey: TARGET_KEY,
207239 data: BASE_STATUS,
208240 error: null,
209241 cause: null,
......@@ -241,6 +273,7 @@ describe("createVcsStatusManager", () => {
241273 for (const listener of connectionListeners) listener();
242274
243275 expect(manager.getSnapshot(TARGET)).toEqual({
276 targetKey: TARGET_KEY,
244277 data: BASE_STATUS,
245278 error: null,
246279 cause: null,
packages/client-runtime/src/vcsStatusState.ts+23-9
......@@ -7,6 +7,7 @@ import type { WsRpcClient } from "./wsRpcClient.ts";
77/* ─── Types ─────────────────────────────────────────────────────────── */
88
99export interface VcsStatusState {
10 readonly targetKey: string | null;
1011 readonly data: VcsStatusResult | null;
1112 readonly error: GitManagerServiceError | null;
1213 readonly cause: Cause.Cause<GitManagerServiceError> | null;
......@@ -30,18 +31,22 @@ interface WatchedEntry {
3031const NOOP: () => void = () => undefined;
3132
3233export const EMPTY_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({
34 targetKey: null,
3335 data: null,
3436 error: null,
3537 cause: null,
3638 isPending: false,
3739});
3840
39const INITIAL_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({
40 data: null,
41 error: null,
42 cause: null,
43 isPending: true,
44});
41function initialVcsStatusState(targetKey: string): VcsStatusState {
42 return {
43 targetKey,
44 data: null,
45 error: null,
46 cause: null,
47 isPending: true,
48 };
49}
4550
4651/* ─── Atoms ─────────────────────────────────────────────────────────── */
4752
......@@ -49,7 +54,7 @@ const knownVcsStatusKeys = new Set<string>();
4954
5055export const vcsStatusStateAtom = Atom.family((key: string) => {
5156 knownVcsStatusKeys.add(key);
52 return Atom.make(INITIAL_VCS_STATUS_STATE).pipe(
57 return Atom.make(initialVcsStatusState(key)).pipe(
5358 Atom.keepAlive,
5459 Atom.withLabel(`vcs-status:${key}`),
5560 );
......@@ -69,6 +74,14 @@ export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null {
6974 return `${target.environmentId}:${target.cwd}`;
7075}
7176
77export function getVcsStatusDataForTarget(
78 state: VcsStatusState,
79 target: VcsStatusTarget,
80): VcsStatusResult | null {
81 const targetKey = getVcsStatusTargetKey(target);
82 return targetKey !== null && state.targetKey === targetKey ? state.data : null;
83}
84
7285/* ─── Subscription manager ──────────────────────────────────────────── */
7386
7487export interface VcsStatusManagerConfig {
......@@ -107,7 +120,7 @@ export function createVcsStatusManager(config: VcsStatusManagerConfig) {
107120 const current = config.getRegistry().get(atom);
108121 const next: VcsStatusState =
109122 current.data === null
110 ? INITIAL_VCS_STATUS_STATE
123 ? initialVcsStatusState(targetKey)
111124 : { ...current, error: null, cause: null, isPending: true };
112125 if (
113126 current.data === next.data &&
......@@ -122,6 +135,7 @@ export function createVcsStatusManager(config: VcsStatusManagerConfig) {
122135
123136 function setData(targetKey: string, status: VcsStatusResult): void {
124137 config.getRegistry().set(vcsStatusStateAtom(targetKey), {
138 targetKey,
125139 data: status,
126140 error: null,
127141 cause: null,
......@@ -283,7 +297,7 @@ export function createVcsStatusManager(config: VcsStatusManagerConfig) {
283297 refreshInFlight.clear();
284298 lastRefreshAt.clear();
285299 for (const key of knownVcsStatusKeys) {
286 config.getRegistry().set(vcsStatusStateAtom(key), INITIAL_VCS_STATUS_STATE);
300 config.getRegistry().set(vcsStatusStateAtom(key), initialVcsStatusState(key));
287301 }
288302 knownVcsStatusKeys.clear();
289303 }