Skip to content

Commit c7460b8

Browse files
Add explicit git status refresh RPC
- Refresh git status after pull and stacked actions - Rehydrate status on window focus and menu open - Wire refresh through server, web, and contracts
1 parent 60b9b6c commit c7460b8

13 files changed

Lines changed: 241 additions & 22 deletions

File tree

apps/server/src/git/Layers/GitStatusBroadcaster.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,12 @@ export const GitStatusBroadcasterLive = Layer.effect(
127127
Effect.gen(function* () {
128128
const normalizedCwd = normalizeCwd(input.cwd);
129129
yield* ensurePoller(normalizedCwd);
130+
const subscription = yield* PubSub.subscribe(changesPubSub);
130131
const initialStatus = yield* getStatus({ cwd: normalizedCwd });
131132

132133
return Stream.concat(
133134
Stream.make(initialStatus),
134-
Stream.fromPubSub(changesPubSub).pipe(
135+
Stream.fromEffectRepeat(PubSub.take(subscription)).pipe(
135136
Stream.filter((event) => event.cwd === normalizedCwd),
136137
Stream.map((event) => event.status),
137138
),

apps/server/src/server.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13901390
);
13911391
assert.equal(pull.status, "pulled");
13921392

1393+
const refreshedStatus = yield* Effect.scoped(
1394+
withWsRpcClient(wsUrl, (client) =>
1395+
client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }),
1396+
),
1397+
);
1398+
assert.equal(refreshedStatus.isRepo, true);
1399+
13931400
const stackedEvents = yield* Effect.scoped(
13941401
withWsRpcClient(wsUrl, (client) =>
13951402
client[WS_METHODS.gitRunStackedAction]({
@@ -1492,11 +1499,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14921499
cwd: "/tmp/repo",
14931500
detail: "upstream missing",
14941501
});
1502+
let invalidationCalls = 0;
1503+
let statusCalls = 0;
14951504
yield* buildAppUnderTest({
14961505
layers: {
14971506
gitCore: {
14981507
pullCurrentBranch: () => Effect.fail(gitError),
14991508
},
1509+
gitManager: {
1510+
invalidateStatus: () =>
1511+
Effect.sync(() => {
1512+
invalidationCalls += 1;
1513+
}),
1514+
status: () =>
1515+
Effect.sync(() => {
1516+
statusCalls += 1;
1517+
return {
1518+
isRepo: true,
1519+
hasOriginRemote: true,
1520+
isDefaultBranch: true,
1521+
branch: "main",
1522+
hasWorkingTreeChanges: true,
1523+
workingTree: { files: [], insertions: 0, deletions: 0 },
1524+
hasUpstream: true,
1525+
aheadCount: 0,
1526+
behindCount: 0,
1527+
pr: null,
1528+
};
1529+
}),
1530+
},
15001531
},
15011532
});
15021533

@@ -1508,6 +1539,63 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
15081539
);
15091540

15101541
assertFailure(result, gitError);
1542+
assert.equal(invalidationCalls, 1);
1543+
assert.equal(statusCalls, 1);
1544+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1545+
);
1546+
1547+
it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () =>
1548+
Effect.gen(function* () {
1549+
const gitError = new GitCommandError({
1550+
operation: "commit",
1551+
command: "git commit",
1552+
cwd: "/tmp/repo",
1553+
detail: "nothing to commit",
1554+
});
1555+
let invalidationCalls = 0;
1556+
let statusCalls = 0;
1557+
yield* buildAppUnderTest({
1558+
layers: {
1559+
gitManager: {
1560+
invalidateStatus: () =>
1561+
Effect.sync(() => {
1562+
invalidationCalls += 1;
1563+
}),
1564+
status: () =>
1565+
Effect.sync(() => {
1566+
statusCalls += 1;
1567+
return {
1568+
isRepo: true,
1569+
hasOriginRemote: true,
1570+
isDefaultBranch: false,
1571+
branch: "feature/demo",
1572+
hasWorkingTreeChanges: true,
1573+
workingTree: { files: [], insertions: 0, deletions: 0 },
1574+
hasUpstream: true,
1575+
aheadCount: 0,
1576+
behindCount: 0,
1577+
pr: null,
1578+
};
1579+
}),
1580+
runStackedAction: () => Effect.fail(gitError),
1581+
},
1582+
},
1583+
});
1584+
1585+
const wsUrl = yield* getWsServerUrl("/ws");
1586+
const result = yield* Effect.scoped(
1587+
withWsRpcClient(wsUrl, (client) =>
1588+
client[WS_METHODS.gitRunStackedAction]({
1589+
actionId: "action-1",
1590+
cwd: "/tmp/repo",
1591+
action: "commit",
1592+
}).pipe(Stream.runCollect, Effect.result),
1593+
),
1594+
);
1595+
1596+
assertFailure(result, gitError);
1597+
assert.equal(invalidationCalls, 1);
1598+
assert.equal(statusCalls, 1);
15111599
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
15121600
);
15131601

apps/server/src/ws.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,10 +570,20 @@ const WsRpcLayer = WsRpcGroup.toLayer(
570570
observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), {
571571
"rpc.aggregate": "git",
572572
}),
573+
[WS_METHODS.gitRefreshStatus]: (input) =>
574+
observeRpcEffect(
575+
WS_METHODS.gitRefreshStatus,
576+
gitStatusBroadcaster.refreshStatus(input.cwd),
577+
{
578+
"rpc.aggregate": "git",
579+
},
580+
),
573581
[WS_METHODS.gitPull]: (input) =>
574582
observeRpcEffect(
575583
WS_METHODS.gitPull,
576-
git.pullCurrentBranch(input.cwd).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
584+
git
585+
.pullCurrentBranch(input.cwd)
586+
.pipe(Effect.ensuring(refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true })))),
577587
{ "rpc.aggregate": "git" },
578588
),
579589
[WS_METHODS.gitRunStackedAction]: (input) =>
@@ -589,7 +599,11 @@ const WsRpcLayer = WsRpcGroup.toLayer(
589599
})
590600
.pipe(
591601
Effect.matchCauseEffect({
592-
onFailure: (cause) => Queue.failCause(queue, cause),
602+
onFailure: (cause) =>
603+
refreshGitStatus(input.cwd).pipe(
604+
Effect.ignore({ log: true }),
605+
Effect.andThen(Queue.failCause(queue, cause)),
606+
),
593607
onSuccess: () =>
594608
refreshGitStatus(input.cwd).pipe(
595609
Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),

apps/web/src/components/GitActionsControl.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
gitPullMutationOptions,
4747
gitRunStackedActionMutationOptions,
4848
} from "~/lib/gitReactQuery";
49-
import { useGitStatus } from "~/lib/gitStatusState";
49+
import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050
import { newCommandId, randomUUID } from "~/lib/utils";
5151
import { resolvePathLinkTarget } from "~/terminal-links";
5252
import { readNativeApi } from "~/nativeApi";
@@ -358,6 +358,29 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
358358
};
359359
}, [updateActiveProgressToast]);
360360

361+
useEffect(() => {
362+
if (gitCwd === null) {
363+
return;
364+
}
365+
366+
const refreshCurrentGitStatus = () => {
367+
void refreshGitStatus(gitCwd).catch(() => undefined);
368+
};
369+
const handleVisibilityChange = () => {
370+
if (document.visibilityState === "visible") {
371+
refreshCurrentGitStatus();
372+
}
373+
};
374+
375+
window.addEventListener("focus", refreshCurrentGitStatus);
376+
document.addEventListener("visibilitychange", handleVisibilityChange);
377+
378+
return () => {
379+
window.removeEventListener("focus", refreshCurrentGitStatus);
380+
document.removeEventListener("visibilitychange", handleVisibilityChange);
381+
};
382+
}, [gitCwd]);
383+
361384
const openExistingPr = useCallback(async () => {
362385
const api = readNativeApi();
363386
if (!api) {
@@ -798,7 +821,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
798821
</Button>
799822
)}
800823
<GroupSeparator className="hidden @3xl/header-actions:block" />
801-
<Menu>
824+
<Menu
825+
onOpenChange={(open) => {
826+
if (open) {
827+
void refreshGitStatus(gitCwd).catch(() => undefined);
828+
}
829+
}}
830+
>
802831
<MenuTrigger
803832
render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}
804833
disabled={isGitActionRunning}

apps/web/src/components/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
298298
selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,
299299
);
300300
const gitCwd = thread?.worktreePath ?? props.projectCwd;
301-
const gitStatus = useGitStatus(thread?.branch !== null ? gitCwd : null);
301+
const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);
302302

303303
if (!thread) {
304304
return null;

apps/web/src/lib/gitReactQuery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient:
106106
if (!input.cwd) throw new Error("Git init is unavailable.");
107107
return api.git.init({ cwd: input.cwd });
108108
},
109-
onSuccess: async () => {
109+
onSettled: async () => {
110110
await invalidateGitBranchQueries(input.queryClient, input.cwd);
111111
},
112112
});
@@ -123,7 +123,7 @@ export function gitCheckoutMutationOptions(input: {
123123
if (!input.cwd) throw new Error("Git checkout is unavailable.");
124124
return api.git.checkout({ cwd: input.cwd, branch });
125125
},
126-
onSuccess: async () => {
126+
onSettled: async () => {
127127
await invalidateGitBranchQueries(input.queryClient, input.cwd);
128128
},
129129
});

apps/web/src/lib/gitStatusState.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
import {
55
getGitStatusSnapshot,
66
resetGitStatusStateForTests,
7+
refreshGitStatus,
78
watchGitStatus,
89
} from "./gitStatusState";
910

@@ -30,6 +31,10 @@ const BASE_STATUS: GitStatusResult = {
3031
};
3132

3233
const gitClient = {
34+
refreshStatus: vi.fn(async (input: { cwd: string }) => ({
35+
...BASE_STATUS,
36+
branch: `${input.cwd}-refreshed`,
37+
})),
3338
onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
3439
registerListener(gitStatusListeners, listener),
3540
),
@@ -44,6 +49,7 @@ function emitGitStatus(event: GitStatusResult) {
4449
afterEach(() => {
4550
gitStatusListeners.clear();
4651
gitClient.onStatus.mockClear();
52+
gitClient.refreshStatus.mockClear();
4753
resetGitStatusStateForTests();
4854
});
4955

@@ -75,4 +81,23 @@ describe("gitStatusState", () => {
7581
releaseB();
7682
expect(gitStatusListeners.size).toBe(0);
7783
});
84+
85+
it("refreshes git status through the unary RPC without restarting the stream", async () => {
86+
const release = watchGitStatus("/repo", gitClient);
87+
88+
emitGitStatus(BASE_STATUS);
89+
const refreshed = await refreshGitStatus("/repo", gitClient);
90+
91+
expect(gitClient.onStatus).toHaveBeenCalledOnce();
92+
expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
93+
expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
94+
expect(getGitStatusSnapshot("/repo")).toEqual({
95+
data: BASE_STATUS,
96+
error: null,
97+
cause: null,
98+
isPending: false,
99+
});
100+
101+
release();
102+
});
78103
});

apps/web/src/lib/gitStatusState.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface GitStatusState {
1616
readonly isPending: boolean;
1717
}
1818

19-
type GitStatusClient = Pick<WsRpcClient["git"], "onStatus">;
19+
type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
2020

2121
interface WatchedGitStatus {
2222
refCount: number;
@@ -33,6 +33,10 @@ const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
3333
const NOOP: () => void = () => undefined;
3434
const watchedGitStatuses = new Map<string, WatchedGitStatus>();
3535
const knownGitStatusCwds = new Set<string>();
36+
const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
37+
const gitStatusLastRefreshAtByCwd = new Map<string, number>();
38+
39+
const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
3640

3741
let sharedGitStatusClient: GitStatusClient | null = null;
3842

@@ -76,11 +80,41 @@ export function watchGitStatus(
7680
return () => unwatchGitStatus(cwd);
7781
}
7882

83+
export function refreshGitStatus(
84+
cwd: string | null,
85+
client: GitStatusClient = getWsRpcClient().git,
86+
): Promise<GitStatusResult | null> {
87+
if (cwd === null) {
88+
return Promise.resolve(null);
89+
}
90+
91+
ensureGitStatusClient(client);
92+
93+
const currentInFlight = gitStatusRefreshInFlight.get(cwd);
94+
if (currentInFlight) {
95+
return currentInFlight;
96+
}
97+
98+
const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0;
99+
if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
100+
return Promise.resolve(getGitStatusSnapshot(cwd).data);
101+
}
102+
103+
gitStatusLastRefreshAtByCwd.set(cwd, Date.now());
104+
const refreshPromise = client.refreshStatus({ cwd }).finally(() => {
105+
gitStatusRefreshInFlight.delete(cwd);
106+
});
107+
gitStatusRefreshInFlight.set(cwd, refreshPromise);
108+
return refreshPromise;
109+
}
110+
79111
export function resetGitStatusStateForTests(): void {
80112
for (const watched of watchedGitStatuses.values()) {
81113
watched.unsubscribe();
82114
}
83115
watchedGitStatuses.clear();
116+
gitStatusRefreshInFlight.clear();
117+
gitStatusLastRefreshAtByCwd.clear();
84118
sharedGitStatusClient = null;
85119

86120
for (const cwd of knownGitStatusCwds) {
@@ -92,7 +126,8 @@ export function resetGitStatusStateForTests(): void {
92126
export function useGitStatus(cwd: string | null): GitStatusState {
93127
useEffect(() => watchGitStatus(cwd), [cwd]);
94128

95-
return cwd === null ? EMPTY_GIT_STATUS_STATE : useAtomValue(gitStatusStateAtom(cwd));
129+
const state = useAtomValue(gitStatusStateAtom(cwd ?? ""));
130+
return cwd === null ? EMPTY_GIT_STATUS_STATE : state;
96131
}
97132

98133
function ensureGitStatusClient(client: GitStatusClient): void {

0 commit comments

Comments
 (0)