Skip to content

Commit 0baf198

Browse files
[codex] Reduce Git status polling churn (#3037)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent ae7e88b commit 0baf198

6 files changed

Lines changed: 227 additions & 3 deletions

File tree

apps/server/src/git/GitManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ export interface GitStatusDetails {
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 {
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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetail
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*
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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Option from "effect/Option";
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", () => {
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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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);

0 commit comments

Comments
 (0)