authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 16:59:57-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 17:46:50-08:00
loga1532d5b2de71e4a2c3d90d9653da02fa931d8d6
treeb577b3c0c22415479c96d699c2ef79f3b61c0f32
parentba4a62eb0335805b6aeab55d55836a2dfbb6d5b5

Refresh git upstream before status and tune Git query polling

- Fetch tracked upstream (throttled + timeout) before `statusDetails` so ahead/behind reflects remote updates - Add integration coverage for behind-count updates after remote commits - Configure React Query stale/refetch intervals for git status and branch lists

3 files changed, 104 insertions(+), 6 deletions(-)

apps/server/src/git.test.ts+29
......@@ -760,6 +760,35 @@ describe("git integration", () => {
760760 expect(dirty.hasWorkingTreeChanges).toBe(true);
761761 });
762762
763 it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => {
764 await using remote = await makeTmpDir();
765 await using source = await makeTmpDir();
766 await using clone = await makeTmpDir();
767 await git(remote.path, "init --bare");
768
769 await initRepoWithCommit(source.path);
770 const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find(
771 (branch) => branch.current,
772 )!.name;
773 await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
774 await git(source.path, `push -u origin ${initialBranch}`);
775
776 await git(clone.path, `clone ${JSON.stringify(remote.path)} .`);
777 await git(clone.path, "config user.email 'test@test.com'");
778 await git(clone.path, "config user.name 'Test'");
779 await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`);
780 await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n");
781 await git(clone.path, "add CHANGELOG.md");
782 await git(clone.path, "commit -m 'remote update'");
783 await git(clone.path, `push origin ${initialBranch}`);
784
785 const core = new GitCoreService();
786 const details = await core.statusDetails(source.path);
787 expect(details.branch).toBe(initialBranch);
788 expect(details.aheadCount).toBe(0);
789 expect(details.behindCount).toBe(1);
790 });
791
763792 it("prepares commit context by auto-staging and creates commit", async () => {
764793 await using tmp = await makeTmpDir();
765794 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+62-6
......@@ -65,6 +65,8 @@ interface ExecuteGitOptions {
6565}
6666
6767const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
68const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;
69const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000;
6870
6971/** Spawn git directly with an argv array — no shell, no quoting needed. */
7072function runGit(
......@@ -216,6 +218,8 @@ async function executeGit(
216218}
217219
218220export class GitCoreService {
221 private readonly lastUpstreamRefreshAt = new Map<string, number>();
222
219223 async status(raw: GitStatusInput): Promise<GitStatusResult> {
220224 const input = gitStatusInputSchema.parse(raw);
221225 const details = await this.statusDetails(input.cwd);
......@@ -231,6 +235,8 @@ export class GitCoreService {
231235 }
232236
233237 async statusDetails(cwd: string): Promise<GitStatusDetails> {
238 await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined);
239
234240 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([
235241 this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]),
236242 this.gitStdout(cwd, ["diff", "--numstat"]),
......@@ -553,7 +559,9 @@ export class GitCoreService {
553559 });
554560 }
555561
556 private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> {
562 private async resolveCurrentUpstream(
563 cwd: string,
564 ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> {
557565 const upstreamRef = trimStdout(
558566 await this.gitStdout(
559567 cwd,
......@@ -562,24 +570,72 @@ export class GitCoreService {
562570 ),
563571 );
564572 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
565 return;
573 return null;
566574 }
567575
568576 const separatorIndex = upstreamRef.indexOf("/");
569577 if (separatorIndex <= 0) {
570 return;
578 return null;
571579 }
572580 const remoteName = upstreamRef.slice(0, separatorIndex);
573581 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
574582 if (remoteName.length === 0) {
575 return;
583 return null;
576584 }
577585 if (upstreamBranch.length === 0) {
586 return null;
587 }
588
589 return {
590 upstreamRef,
591 remoteName,
592 upstreamBranch,
593 };
594 }
595
596 private async fetchUpstreamRef(
597 cwd: string,
598 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
599 ): Promise<void> {
600 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
601 await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true);
602 }
603
604 private async fetchUpstreamRefForStatus(
605 cwd: string,
606 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
607 ): Promise<void> {
608 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
609 await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], {
610 allowNonZeroExit: true,
611 timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS,
612 });
613 }
614
615 private async refreshStatusUpstreamIfStale(cwd: string): Promise<void> {
616 const upstream = await this.resolveCurrentUpstream(cwd);
617 if (!upstream) {
578618 return;
579619 }
580620
581 const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`;
582 await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true);
621 const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`;
622 const now = Date.now();
623 const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0;
624 if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) {
625 return;
626 }
627
628 // Record attempt time first to avoid repeated fetch spikes when status is polled often.
629 this.lastUpstreamRefreshAt.set(cacheKey, now);
630 await this.fetchUpstreamRefForStatus(cwd, upstream);
631 }
632
633 private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> {
634 const upstream = await this.resolveCurrentUpstream(cwd);
635 if (!upstream) {
636 return;
637 }
638 await this.fetchUpstreamRef(cwd, upstream);
583639 }
584640
585641 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
apps/web/src/lib/gitReactQuery.ts+13
......@@ -1,6 +1,11 @@
11import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
22import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33
4const GIT_STATUS_STALE_TIME_MS = 5_000;
5const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
6const GIT_BRANCHES_STALE_TIME_MS = 15_000;
7const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
8
49export const gitQueryKeys = {
510 all: ["git"] as const,
611 status: (cwd: string | null) => ["git", "status", cwd] as const,
......@@ -21,6 +26,10 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |
2126 return api.git.status({ cwd });
2227 },
2328 enabled: !!api && !!cwd,
29 staleTime: GIT_STATUS_STALE_TIME_MS,
30 refetchOnWindowFocus: "always",
31 refetchOnReconnect: "always",
32 refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS,
2433 });
2534}
2635
......@@ -34,6 +43,10 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string
3443 return api.git.listBranches({ cwd });
3544 },
3645 enabled: !!api && !!cwd,
46 staleTime: GIT_BRANCHES_STALE_TIME_MS,
47 refetchOnWindowFocus: true,
48 refetchOnReconnect: true,
49 refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS,
3750 });
3851}
3952