History

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

Julius Marminge committed 5 months agov0.0.28-nightly.20260611.526

Commit 0baf1981 parent ae7e88b6 files · +227 −3Browse files

Unified · Split

6 files
  1. apps/server/src/git/GitManager.ts +1 −1
  2. apps/server/src/vcs/GitVcsDriver.ts +14 −0
  3. apps/server/src/vcs/GitVcsDriverCore.test.ts +61 −0
  4. apps/server/src/vcs/GitVcsDriverCore.ts +93 −0
  5. apps/server/src/vcs/VcsStatusBroadcaster.test.ts +43 −0
  6. apps/server/src/vcs/VcsStatusBroadcaster.ts +15 −2
apps/server/src/git/GitManager.ts+1−1
@@ -747,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 746 unmodified lines
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−0
@@ -68,6 +68,17 @@ export interface GitStatusDetails { · 67 unmodified lines
6868 aheadOfDefaultCount: number;
6969 }
7070
71+export 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+
7182 export interface GitPreparedCommitContext {
7283 stagedSummary: string;
7384 stagedPatch: string;
@@ -162,6 +173,9 @@ export interface GitVcsDriverShape { · 88 unmodified lines
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−0
@@ -155,6 +155,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 154 unmodified lines
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−0
@@ -71,6 +71,16 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetail · 70 unmodified lines
7171 behindCount: 0,
7272 aheadOfDefaultCount: 0,
7373 });
74+const 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
7585 type TraceTailState = {
7686 processedChars: number;
@@ -1154,6 +1164,78 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1077 unmodified lines
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* · 196 unmodified lines
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* · 940 unmodified lines
23022394 status,
23032395 statusDetails,
23042396 statusDetailsLocal,
2397+ statusDetailsRemote,
23052398 prepareCommitContext,
23062399 commit,
23072400 pushCurrentBranch,
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+43−0
@@ -10,6 +10,7 @@ import * as Option from "effect/Option"; · 9 unmodified lines
1010 import * as Path from "effect/Path";
1111 import * as Scope from "effect/Scope";
1212 import * as Stream from "effect/Stream";
13+import * as TestClock from "effect/testing/TestClock";
1314 import type {
1415 VcsStatusLocalResult,
1516 VcsStatusRemoteResult,
@@ -310,6 +311,48 @@ describe("VcsStatusBroadcaster", () => { · 294 unmodified lines
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( · 256 unmodified lines
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( · 26 unmodified lines
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( · 8 unmodified lines
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( · 6 unmodified lines
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( · 41 unmodified lines
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);