authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 00:25:56-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 00:53:20-08:00
logf145a453b56828af4ad61263ca267ebaeec5a141
treec055f6ceef8fab3edae3f4ba9aa1c01b595db2c3
parent1f4f693dd6525814996741c32a97dda0ed574939

Refresh branch upstream after checkout and invalidate git queries

- fetch tracked remote after checkout to update ahead/behind counts - add integration test for behind count when remote branch advances - invalidate git React Query data on `turn/completed` events in the web app

3 files changed, 75 insertions(+), 2 deletions(-)

apps/server/src/git.test.ts+40
...@@ -239,6 +239,46 @@ describe("git integration", () => {...@@ -239,6 +239,46 @@ describe("git integration", () => {
239 expect(current!.name).toBe("feature");239 expect(current!.name).toBe("feature");
240 });240 });
241241
242 it("refreshes upstream behind count after checkout when remote branch advanced", async () => {
243 await using remote = await makeTmpDir();
244 await using source = await makeTmpDir();
245 await using clone = await makeTmpDir();
246 await git(remote.path, "init --bare");
247
248 await initRepoWithCommit(source.path);
249 const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(
250 (branch) => branch.current,
251 )!.name;
252 await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
253 await git(source.path, `push -u origin ${defaultBranch}`);
254
255 const featureBranch = "feature-behind";
256 await createGitBranch({ cwd: source.path, branch: featureBranch });
257 await checkoutGitBranch({ cwd: source.path, branch: featureBranch });
258 await writeFile(path.join(source.path, "feature.txt"), "feature base\n");
259 await git(source.path, "add feature.txt");
260 await git(source.path, "commit -m 'feature base'");
261 await git(source.path, `push -u origin ${featureBranch}`);
262 await checkoutGitBranch({ cwd: source.path, branch: defaultBranch });
263
264 await git(clone.path, `clone ${JSON.stringify(remote.path)} .`);
265 await git(clone.path, "config user.email 'test@test.com'");
266 await git(clone.path, "config user.name 'Test'");
267 await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`);
268 await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n");
269 await git(clone.path, "add feature.txt");
270 await git(clone.path, "commit -m 'remote feature update'");
271 await git(clone.path, `push origin ${featureBranch}`);
272
273 await checkoutGitBranch({ cwd: source.path, branch: featureBranch });
274 const core = new GitCoreService();
275 const details = await core.statusDetails(source.path);
276
277 expect(details.branch).toBe(featureBranch);
278 expect(details.aheadCount).toBe(0);
279 expect(details.behindCount).toBe(1);
280 });
281
242 it("throws when branch does not exist", async () => {282 it("throws when branch does not exist", async () => {
243 await using tmp = await makeTmpDir();283 await using tmp = await makeTmpDir();
244 await initRepoWithCommit(tmp.path);284 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+28
...@@ -553,11 +553,39 @@ export class GitCoreService {...@@ -553,11 +553,39 @@ export class GitCoreService {
553 });553 });
554 }554 }
555555
556 private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> {
557 const upstreamRef = trimStdout(
558 await this.gitStdout(
559 cwd,
560 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
561 true,
562 ),
563 );
564 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
565 return;
566 }
567
568 const separatorIndex = upstreamRef.indexOf("/");
569 if (separatorIndex <= 0) {
570 return;
571 }
572 const remoteName = upstreamRef.slice(0, separatorIndex);
573 if (remoteName.length === 0) {
574 return;
575 }
576
577 await executeGit(cwd, ["fetch", "--quiet", "--no-tags", remoteName], {
578 timeoutMs: 20_000,
579 allowNonZeroExit: true,
580 });
581 }
582
556 async checkoutBranch(input: GitCheckoutInput): Promise<void> {583 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
557 await executeGit(input.cwd, ["checkout", input.branch], {584 await executeGit(input.cwd, ["checkout", input.branch], {
558 timeoutMs: 10_000,585 timeoutMs: 10_000,
559 fallbackErrorMessage: "git checkout failed",586 fallbackErrorMessage: "git checkout failed",
560 });587 });
588 await this.refreshCheckedOutBranchUpstream(input.cwd);
561 }589 }
562590
563 async initRepo(input: GitInitInput): Promise<void> {591 async initRepo(input: GitInitInput): Promise<void> {
apps/web/src/App.tsx+7-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
2import { useEffect, useRef } from "react";2import { useEffect, useRef } from "react";
33
4import ChatView from "./components/ChatView";4import ChatView from "./components/ChatView";
...@@ -11,22 +11,27 @@ import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./ty...@@ -11,22 +11,27 @@ import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./ty
11import { onServerWelcome } from "./wsNativeApi";11import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";12import { useNativeApi } from "./hooks/useNativeApi";
13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14import { invalidateGitQueries } from "./lib/gitReactQuery";
1415
15function EventRouter() {16function EventRouter() {
16 const api = useNativeApi();17 const api = useNativeApi();
17 const { dispatch } = useStore();18 const { dispatch } = useStore();
19 const queryClient = useQueryClient();
18 const activeAssistantItemRef = useRef<string | null>(null);20 const activeAssistantItemRef = useRef<string | null>(null);
1921
20 useEffect(() => {22 useEffect(() => {
21 if (!api) return;23 if (!api) return;
22 return api.providers.onEvent((event) => {24 return api.providers.onEvent((event) => {
25 if (event.method === "turn/completed") {
26 void invalidateGitQueries(queryClient);
27 }
23 dispatch({28 dispatch({
24 type: "APPLY_EVENT",29 type: "APPLY_EVENT",
25 event,30 event,
26 activeAssistantItemRef,31 activeAssistantItemRef,
27 });32 });
28 });33 });
29 }, [api, dispatch]);34 }, [api, dispatch, queryClient]);
3035
31 useEffect(() => {36 useEffect(() => {
32 if (!api) return;37 if (!api) return;