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", () => {...@@ -760,6 +760,35 @@ describe("git integration", () => {
760 expect(dirty.hasWorkingTreeChanges).toBe(true);760 expect(dirty.hasWorkingTreeChanges).toBe(true);
761 });761 });
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
763 it("prepares commit context by auto-staging and creates commit", async () => {792 it("prepares commit context by auto-staging and creates commit", async () => {
764 await using tmp = await makeTmpDir();793 await using tmp = await makeTmpDir();
765 await initRepoWithCommit(tmp.path);794 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+62-6
...@@ -65,6 +65,8 @@ interface ExecuteGitOptions {...@@ -65,6 +65,8 @@ interface ExecuteGitOptions {
65}65}
6666
67const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;67const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
68const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;
69const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000;
6870
69/** Spawn git directly with an argv array — no shell, no quoting needed. */71/** Spawn git directly with an argv array — no shell, no quoting needed. */
70function runGit(72function runGit(
...@@ -216,6 +218,8 @@ async function executeGit(...@@ -216,6 +218,8 @@ async function executeGit(
216}218}
217219
218export class GitCoreService {220export class GitCoreService {
221 private readonly lastUpstreamRefreshAt = new Map<string, number>();
222
219 async status(raw: GitStatusInput): Promise<GitStatusResult> {223 async status(raw: GitStatusInput): Promise<GitStatusResult> {
220 const input = gitStatusInputSchema.parse(raw);224 const input = gitStatusInputSchema.parse(raw);
221 const details = await this.statusDetails(input.cwd);225 const details = await this.statusDetails(input.cwd);
...@@ -231,6 +235,8 @@ export class GitCoreService {...@@ -231,6 +235,8 @@ export class GitCoreService {
231 }235 }
232236
233 async statusDetails(cwd: string): Promise<GitStatusDetails> {237 async statusDetails(cwd: string): Promise<GitStatusDetails> {
238 await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined);
239
234 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([240 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([
235 this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]),241 this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]),
236 this.gitStdout(cwd, ["diff", "--numstat"]),242 this.gitStdout(cwd, ["diff", "--numstat"]),
...@@ -553,7 +559,9 @@ export class GitCoreService {...@@ -553,7 +559,9 @@ export class GitCoreService {
553 });559 });
554 }560 }
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> {
557 const upstreamRef = trimStdout(565 const upstreamRef = trimStdout(
558 await this.gitStdout(566 await this.gitStdout(
559 cwd,567 cwd,
...@@ -562,24 +570,72 @@ export class GitCoreService {...@@ -562,24 +570,72 @@ export class GitCoreService {
562 ),570 ),
563 );571 );
564 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {572 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
565 return;573 return null;
566 }574 }
567575
568 const separatorIndex = upstreamRef.indexOf("/");576 const separatorIndex = upstreamRef.indexOf("/");
569 if (separatorIndex <= 0) {577 if (separatorIndex <= 0) {
570 return;578 return null;
571 }579 }
572 const remoteName = upstreamRef.slice(0, separatorIndex);580 const remoteName = upstreamRef.slice(0, separatorIndex);
573 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);581 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
574 if (remoteName.length === 0) {582 if (remoteName.length === 0) {
575 return;583 return null;
576 }584 }
577 if (upstreamBranch.length === 0) {585 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) {
578 return;618 return;
579 }619 }
580620
581 const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`;621 const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`;
582 await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true);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);
583 }639 }
584640
585 async checkoutBranch(input: GitCheckoutInput): Promise<void> {641 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
apps/web/src/lib/gitReactQuery.ts+13
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";2import { 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
4export const gitQueryKeys = {9export const gitQueryKeys = {
5 all: ["git"] as const,10 all: ["git"] as const,
6 status: (cwd: string | null) => ["git", "status", cwd] as const,11 status: (cwd: string | null) => ["git", "status", cwd] as const,
...@@ -21,6 +26,10 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |...@@ -21,6 +26,10 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |
21 return api.git.status({ cwd });26 return api.git.status({ cwd });
22 },27 },
23 enabled: !!api && !!cwd,28 enabled: !!api && !!cwd,
29 staleTime: GIT_STATUS_STALE_TIME_MS,
30 refetchOnWindowFocus: "always",
31 refetchOnReconnect: "always",
32 refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS,
24 });33 });
25}34}
2635
...@@ -34,6 +43,10 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string...@@ -34,6 +43,10 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string
34 return api.git.listBranches({ cwd });43 return api.git.listBranches({ cwd });
35 },44 },
36 enabled: !!api && !!cwd,45 enabled: !!api && !!cwd,
46 staleTime: GIT_BRANCHES_STALE_TIME_MS,
47 refetchOnWindowFocus: true,
48 refetchOnReconnect: true,
49 refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS,
37 });50 });
38}51}
3952