authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 17:10:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 17:10:19-07:00
log0baf1986e8261c62035cbebc326d0da4ffd2defb
treecd80660710300cc8aacfa33347f616275c438071
parentae7e88b0e0bc4acac33bec4f6e798a7ad96500a1
signature Signed by PGP key B5690EEEBB952194

[codex] Reduce Git status polling churn (#3037)

Co-authored-by: codex <codex@users.noreply.github.com>

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

apps/server/src/git/GitManager.ts+1-1
......@@ -747,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
747747 );
748748 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
749749 const details = yield* gitCore
750 .statusDetails(cwd)
750 .statusDetailsRemote(cwd)
751751 .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
752752 if (details === null || !details.isRepo) {
753753 return null;
apps/server/src/vcs/GitVcsDriver.ts+14
......@@ -68,6 +68,17 @@ export interface GitStatusDetails {
6868 aheadOfDefaultCount: number;
6969}
7070
71export interface GitRemoteStatusDetails {
72 isRepo: boolean;
73 isDefaultBranch: boolean;
74 branch: string | null;
75 upstreamRef: string | null;
76 hasUpstream: boolean;
77 aheadCount: number;
78 behindCount: number;
79 aheadOfDefaultCount: number;
80}
81
7182export interface GitPreparedCommitContext {
7283 stagedSummary: string;
7384 stagedPatch: string;
......@@ -162,6 +173,9 @@ export interface GitVcsDriverShape {
162173 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
163174 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
164175 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
176 readonly statusDetailsRemote: (
177 cwd: string,
178 ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;
165179 readonly prepareCommitContext: (
166180 cwd: string,
167181 filePaths?: readonly string[],
apps/server/src/vcs/GitVcsDriverCore.test.ts+61
......@@ -155,6 +155,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
155155 }),
156156 );
157157
158 it.effect("reports remote divergence without reading working-tree details", () =>
159 Effect.gen(function* () {
160 const cwd = yield* makeTmpDir();
161 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
162 const { initialBranch } = yield* initRepoWithCommit(cwd);
163 yield* git(remote, ["init", "--bare"]);
164 yield* git(cwd, ["remote", "add", "origin", remote]);
165 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
166 yield* git(cwd, ["checkout", "-b", "feature/remote-status"]);
167 yield* writeTextFile(cwd, "feature.txt", "feature\n");
168 yield* git(cwd, ["add", "feature.txt"]);
169 yield* git(cwd, ["commit", "-m", "feature commit"]);
170 yield* git(cwd, ["push", "-u", "origin", "feature/remote-status"]);
171 yield* writeTextFile(cwd, "untracked.txt", "local-only\n");
172
173 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd);
174
175 assert.equal(status.isRepo, true);
176 assert.equal(status.branch, "feature/remote-status");
177 assert.equal(status.hasUpstream, true);
178 assert.equal(status.aheadCount, 0);
179 assert.equal(status.behindCount, 0);
180 assert.equal(status.aheadOfDefaultCount, 1);
181 assert.notProperty(status, "workingTree");
182 assert.notProperty(status, "hasWorkingTreeChanges");
183 }),
184 );
185
186 it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () =>
187 Effect.gen(function* () {
188 const cwd = yield* makeTmpDir();
189 const origin = yield* makeTmpDir("git-vcs-driver-origin-");
190 const upstream = yield* makeTmpDir("git-vcs-driver-upstream-");
191 yield* initRepoWithCommit(cwd);
192 yield* git(origin, ["init", "--bare"]);
193 yield* git(upstream, ["init", "--bare"]);
194 yield* git(cwd, ["branch", "-M", "main"]);
195 yield* git(cwd, ["remote", "add", "origin", origin]);
196 yield* git(cwd, ["remote", "add", "upstream", upstream]);
197 yield* git(cwd, ["push", "origin", "main"]);
198 yield* git(cwd, ["push", "upstream", "main"]);
199 yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
200 yield* git(cwd, ["checkout", "-b", "release"]);
201 yield* writeTextFile(cwd, "release.txt", "release\n");
202 yield* git(cwd, ["add", "release.txt"]);
203 yield* git(cwd, ["commit", "-m", "release commit"]);
204 yield* git(cwd, ["push", "-u", "upstream", "release"]);
205 yield* git(cwd, [
206 "symbolic-ref",
207 "refs/remotes/upstream/HEAD",
208 "refs/remotes/upstream/release",
209 ]);
210
211 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd);
212
213 assert.equal(status.branch, "release");
214 assert.equal(status.upstreamRef, "upstream/release");
215 assert.equal(status.isDefaultBranch, false);
216 }),
217 );
218
158219 it.effect("disables SSH askpass for background upstream status fetches", () =>
159220 Effect.gen(function* () {
160221 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+93
......@@ -71,6 +71,16 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetail
7171 behindCount: 0,
7272 aheadOfDefaultCount: 0,
7373});
74const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({
75 isRepo: false,
76 isDefaultBranch: false,
77 branch: null,
78 upstreamRef: null,
79 hasUpstream: false,
80 aheadCount: 0,
81 behindCount: 0,
82 aheadOfDefaultCount: 0,
83});
7484
7585type TraceTailState = {
7686 processedChars: number;
......@@ -1154,6 +1164,78 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
11541164 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
11551165 });
11561166
1167 const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) {
1168 const branchResult = yield* executeGit(
1169 "GitVcsDriver.statusDetailsRemote.branch",
1170 cwd,
1171 ["rev-parse", "--abbrev-ref", "HEAD"],
1172 { allowNonZeroExit: true },
1173 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1174
1175 if (branchResult === null) {
1176 return NON_REPOSITORY_REMOTE_STATUS_DETAILS;
1177 }
1178 if (branchResult.exitCode !== 0) {
1179 const stderr = branchResult.stderr.trim();
1180 return yield* createGitCommandError(
1181 "GitVcsDriver.statusDetailsRemote.branch",
1182 cwd,
1183 ["rev-parse", "--abbrev-ref", "HEAD"],
1184 stderr || "git branch lookup failed",
1185 );
1186 }
1187
1188 const branchValue = branchResult.stdout.trim();
1189 const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null;
1190 const upstream = yield* resolveCurrentUpstream(cwd);
1191 const upstreamRef = upstream?.upstreamRef ?? null;
1192 let aheadCount = 0;
1193 let behindCount = 0;
1194
1195 if (upstreamRef) {
1196 const divergence = yield* executeGit(
1197 "GitVcsDriver.statusDetailsRemote.divergence",
1198 cwd,
1199 ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`],
1200 { allowNonZeroExit: true },
1201 );
1202 if (divergence.exitCode === 0) {
1203 const [aheadRaw, behindRaw] = divergence.stdout.trim().split(/\s+/);
1204 const parsedAhead = Number.parseInt(aheadRaw ?? "0", 10);
1205 const parsedBehind = Number.parseInt(behindRaw ?? "0", 10);
1206 aheadCount = Number.isFinite(parsedAhead) ? Math.max(0, parsedAhead) : 0;
1207 behindCount = Number.isFinite(parsedBehind) ? Math.max(0, parsedBehind) : 0;
1208 }
1209 } else if (branch) {
1210 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
1211 Effect.orElseSucceed(() => 0),
1212 );
1213 }
1214
1215 const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin");
1216 const isDefaultBranch =
1217 branch !== null &&
1218 (branch === defaultBranch ||
1219 (defaultBranch === null && (branch === "main" || branch === "master")));
1220 const aheadOfDefaultCount =
1221 branch && !isDefaultBranch
1222 ? upstreamRef === null
1223 ? aheadCount
1224 : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0))
1225 : 0;
1226
1227 return {
1228 isRepo: true,
1229 isDefaultBranch,
1230 branch,
1231 upstreamRef,
1232 hasUpstream: upstreamRef !== null,
1233 aheadCount,
1234 behindCount,
1235 aheadOfDefaultCount,
1236 };
1237 });
1238
11571239 const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) {
11581240 const branchRecency = yield* executeGit(
11591241 "GitVcsDriver.readBranchRecency",
......@@ -1356,6 +1438,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
13561438 },
13571439 );
13581440
1441 const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn(
1442 "statusDetailsRemote",
1443 )(function* (cwd) {
1444 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1445 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1446 Effect.ignoreCause({ log: true }),
1447 );
1448 return yield* readStatusDetailsRemote(cwd);
1449 });
1450
13591451 const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) =>
13601452 statusDetails(input.cwd).pipe(
13611453 Effect.map((details) => ({
......@@ -2302,6 +2394,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23022394 status,
23032395 statusDetails,
23042396 statusDetailsLocal,
2397 statusDetailsRemote,
23052398 prepareCommitContext,
23062399 commit,
23072400 pushCurrentBranch,
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+43
......@@ -10,6 +10,7 @@ import * as Option from "effect/Option";
1010import * as Path from "effect/Path";
1111import * as Scope from "effect/Scope";
1212import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
1314import type {
1415 VcsStatusLocalResult,
1516 VcsStatusRemoteResult,
......@@ -310,6 +311,48 @@ describe("VcsStatusBroadcaster", () => {
310311 }).pipe(Effect.provide(makeTestLayer(state)));
311312 });
312313
314 it.effect("delays automatic refresh when a cached remote snapshot is available", () => {
315 const state = {
316 currentLocalStatus: baseLocalStatus,
317 currentRemoteStatus: baseRemoteStatus,
318 localStatusCalls: 0,
319 remoteStatusCalls: 0,
320 localInvalidationCalls: 0,
321 remoteInvalidationCalls: 0,
322 };
323
324 return Effect.gen(function* () {
325 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
326 yield* broadcaster.getStatus({ cwd: "/repo" });
327 const scope = yield* Scope.make();
328 const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
329 yield* Stream.runForEach(
330 broadcaster.streamStatus(
331 { cwd: "/repo" },
332 { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) },
333 ),
334 (event) =>
335 event._tag === "snapshot"
336 ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore)
337 : Effect.void,
338 ).pipe(Effect.forkIn(scope));
339
340 yield* Deferred.await(snapshotDeferred);
341 assert.equal(state.remoteStatusCalls, 1);
342 assert.equal(state.remoteInvalidationCalls, 0);
343
344 yield* TestClock.adjust(Duration.seconds(59));
345 assert.equal(state.remoteStatusCalls, 1);
346
347 yield* TestClock.adjust(Duration.seconds(1));
348 yield* Effect.yieldNow;
349 assert.equal(state.remoteStatusCalls, 2);
350 assert.equal(state.remoteInvalidationCalls, 1);
351
352 yield* Scope.close(scope, Exit.void);
353 }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer())));
354 });
355
313356 it("backs off remote refresh failures exponentially and honors larger configured intervals", () => {
314357 assert.equal(
315358 Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))),
apps/server/src/vcs/VcsStatusBroadcaster.ts+15-2
......@@ -257,6 +257,7 @@ export const layer = Layer.effect(
257257 const makeRemoteRefreshLoop = (
258258 cwd: string,
259259 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
260 refreshImmediately: boolean,
260261 ) => {
261262 return Effect.gen(function* () {
262263 const consecutiveFailuresRef = yield* Ref.make(0);
......@@ -289,6 +290,15 @@ export const layer = Layer.effect(
289290 return nextDelay;
290291 });
291292
293 if (!refreshImmediately) {
294 const configuredInterval = yield* automaticRemoteRefreshInterval;
295 yield* Effect.sleep(
296 Duration.isZero(configuredInterval)
297 ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL
298 : configuredInterval,
299 );
300 }
301
292302 return yield* refreshRemoteStatusIfEnabled.pipe(
293303 Effect.repeat(
294304 Schedule.identity<Duration.Duration>().pipe(
......@@ -303,6 +313,7 @@ export const layer = Layer.effect(
303313 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
304314 cwd: string,
305315 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
316 refreshImmediately: boolean,
306317 ) {
307318 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
308319 const existing = activePollers.get(cwd);
......@@ -315,7 +326,7 @@ export const layer = Layer.effect(
315326 return Effect.succeed([undefined, nextPollers] as const);
316327 }
317328
318 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe(
329 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe(
319330 Effect.forkIn(broadcasterScope),
320331 Effect.map((fiber) => {
321332 const nextPollers = new Map(activePollers);
......@@ -363,11 +374,13 @@ export const layer = Layer.effect(
363374 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
364375 const subscription = yield* PubSub.subscribe(changesPubSub);
365376 const initialLocal = yield* getOrLoadLocalStatus(cwd);
366 const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null;
377 const cachedStatus = yield* getCachedStatus(cwd);
378 const initialRemote = cachedStatus?.remote?.value ?? null;
367379 yield* retainRemotePoller(
368380 cwd,
369381 options?.automaticRemoteRefreshInterval ??
370382 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
383 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
371384 );
372385
373386 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);