diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts index 515a763cf727dc19df843e4986c7dc8cdc969608..5c46c27ba271450d3069f605737b644580ef015d 100644 --- a/apps/server/src/git.test.ts +++ b/apps/server/src/git.test.ts @@ -760,6 +760,35 @@ describe("git integration", () => { expect(dirty.hasWorkingTreeChanges).toBe(true); }); + it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => { + await using remote = await makeTmpDir(); + await using source = await makeTmpDir(); + await using clone = await makeTmpDir(); + await git(remote.path, "init --bare"); + + await initRepoWithCommit(source.path); + const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( + (branch) => branch.current, + )!.name; + await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); + await git(source.path, `push -u origin ${initialBranch}`); + + await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); + await git(clone.path, "config user.email 'test@test.com'"); + await git(clone.path, "config user.name 'Test'"); + await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`); + await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); + await git(clone.path, "add CHANGELOG.md"); + await git(clone.path, "commit -m 'remote update'"); + await git(clone.path, `push origin ${initialBranch}`); + + const core = new GitCoreService(); + const details = await core.statusDetails(source.path); + expect(details.branch).toBe(initialBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }); + it("prepares commit context by auto-staging and creates commit", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 1fbd86c7bee1a07cdeea77b3101fa332ac136a91..c72ea3791cff6db8e5bdee69e50f77c7739b0d61 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -65,6 +65,8 @@ interface ExecuteGitOptions { } const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; +const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; /** Spawn git directly with an argv array — no shell, no quoting needed. */ function runGit( @@ -216,6 +218,8 @@ async function executeGit( } export class GitCoreService { + private readonly lastUpstreamRefreshAt = new Map(); + async status(raw: GitStatusInput): Promise { const input = gitStatusInputSchema.parse(raw); const details = await this.statusDetails(input.cwd); @@ -231,6 +235,8 @@ export class GitCoreService { } async statusDetails(cwd: string): Promise { + await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); + const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), this.gitStdout(cwd, ["diff", "--numstat"]), @@ -553,7 +559,9 @@ export class GitCoreService { }); } - private async refreshCheckedOutBranchUpstream(cwd: string): Promise { + private async resolveCurrentUpstream( + cwd: string, + ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { const upstreamRef = trimStdout( await this.gitStdout( cwd, @@ -562,24 +570,72 @@ export class GitCoreService { ), ); if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { - return; + return null; } const separatorIndex = upstreamRef.indexOf("/"); if (separatorIndex <= 0) { - return; + return null; } const remoteName = upstreamRef.slice(0, separatorIndex); const upstreamBranch = upstreamRef.slice(separatorIndex + 1); if (remoteName.length === 0) { - return; + return null; } if (upstreamBranch.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + upstreamBranch, + }; + } + + private async fetchUpstreamRef( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Promise { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); + } + + private async fetchUpstreamRefForStatus( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Promise { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { + allowNonZeroExit: true, + timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, + }); + } + + private async refreshStatusUpstreamIfStale(cwd: string): Promise { + const upstream = await this.resolveCurrentUpstream(cwd); + if (!upstream) { return; } - const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`; - await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true); + const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; + const now = Date.now(); + const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; + if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { + return; + } + + // Record attempt time first to avoid repeated fetch spikes when status is polled often. + this.lastUpstreamRefreshAt.set(cacheKey, now); + await this.fetchUpstreamRefForStatus(cwd, upstream); + } + + private async refreshCheckedOutBranchUpstream(cwd: string): Promise { + const upstream = await this.resolveCurrentUpstream(cwd); + if (!upstream) { + return; + } + await this.fetchUpstreamRef(cwd, upstream); } async checkoutBranch(input: GitCheckoutInput): Promise { diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index a051fa8a0423fe21f0d7582335e691331b984b63..cd39aed6419850a526542f632990690633bf3798 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,6 +1,11 @@ import type { GitStackedAction, NativeApi } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; +const GIT_STATUS_STALE_TIME_MS = 5_000; +const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; +const GIT_BRANCHES_STALE_TIME_MS = 15_000; +const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; + export const gitQueryKeys = { all: ["git"] as const, status: (cwd: string | null) => ["git", "status", cwd] as const, @@ -21,6 +26,10 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | return api.git.status({ cwd }); }, enabled: !!api && !!cwd, + staleTime: GIT_STATUS_STALE_TIME_MS, + refetchOnWindowFocus: "always", + refetchOnReconnect: "always", + refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, }); } @@ -34,6 +43,10 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string return api.git.listBranches({ cwd }); }, enabled: !!api && !!cwd, + staleTime: GIT_BRANCHES_STALE_TIME_MS, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS, }); }