| author | |
| committer | |
| log | 0baf1986e8261c62035cbebc326d0da4ffd2defb |
| tree | cd80660710300cc8aacfa33347f616275c438071 |
| parent | ae7e88b0e0bc4acac33bec4f6e798a7ad96500a1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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* () { |
| 747 | 747 | ); |
| 748 | 748 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { |
| 749 | 749 | const details = yield* gitCore |
| 750 | .statusDetails(cwd) | |
| 750 | .statusDetailsRemote(cwd) | |
| 751 | 751 | .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); |
| 752 | 752 | if (details === null || !details.isRepo) { |
| 753 | 753 | return null; |
apps/server/src/vcs/GitVcsDriver.ts+14| ... | ... | @@ -68,6 +68,17 @@ export interface GitStatusDetails { |
| 68 | 68 | aheadOfDefaultCount: number; |
| 69 | 69 | } |
| 70 | 70 | |
| 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 | ||
| 71 | 82 | export interface GitPreparedCommitContext { |
| 72 | 83 | stagedSummary: string; |
| 73 | 84 | stagedPatch: string; |
| ... | ... | @@ -162,6 +173,9 @@ export interface GitVcsDriverShape { |
| 162 | 173 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; |
| 163 | 174 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; |
| 164 | 175 | readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; |
| 176 | readonly statusDetailsRemote: ( | |
| 177 | cwd: string, | |
| 178 | ) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>; | |
| 165 | 179 | readonly prepareCommitContext: ( |
| 166 | 180 | cwd: string, |
| 167 | 181 | filePaths?: readonly string[], |
apps/server/src/vcs/GitVcsDriverCore.test.ts+61| ... | ... | @@ -155,6 +155,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 155 | 155 | }), |
| 156 | 156 | ); |
| 157 | 157 | |
| 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 | ||
| 158 | 219 | it.effect("disables SSH askpass for background upstream status fetches", () => |
| 159 | 220 | Effect.gen(function* () { |
| 160 | 221 | const cwd = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+93| ... | ... | @@ -71,6 +71,16 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetail |
| 71 | 71 | behindCount: 0, |
| 72 | 72 | aheadOfDefaultCount: 0, |
| 73 | 73 | }); |
| 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 | }); | |
| 74 | 84 | |
| 75 | 85 | type TraceTailState = { |
| 76 | 86 | processedChars: number; |
| ... | ... | @@ -1154,6 +1164,78 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1154 | 1164 | return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; |
| 1155 | 1165 | }); |
| 1156 | 1166 | |
| 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 | ||
| 1157 | 1239 | const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { |
| 1158 | 1240 | const branchRecency = yield* executeGit( |
| 1159 | 1241 | "GitVcsDriver.readBranchRecency", |
| ... | ... | @@ -1356,6 +1438,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1356 | 1438 | }, |
| 1357 | 1439 | ); |
| 1358 | 1440 | |
| 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 | ||
| 1359 | 1451 | const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => |
| 1360 | 1452 | statusDetails(input.cwd).pipe( |
| 1361 | 1453 | Effect.map((details) => ({ |
| ... | ... | @@ -2302,6 +2394,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2302 | 2394 | status, |
| 2303 | 2395 | statusDetails, |
| 2304 | 2396 | statusDetailsLocal, |
| 2397 | statusDetailsRemote, | |
| 2305 | 2398 | prepareCommitContext, |
| 2306 | 2399 | commit, |
| 2307 | 2400 | pushCurrentBranch, |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+43| ... | ... | @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; |
| 10 | 10 | import * as Path from "effect/Path"; |
| 11 | 11 | import * as Scope from "effect/Scope"; |
| 12 | 12 | import * as Stream from "effect/Stream"; |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 13 | 14 | import type { |
| 14 | 15 | VcsStatusLocalResult, |
| 15 | 16 | VcsStatusRemoteResult, |
| ... | ... | @@ -310,6 +311,48 @@ describe("VcsStatusBroadcaster", () => { |
| 310 | 311 | }).pipe(Effect.provide(makeTestLayer(state))); |
| 311 | 312 | }); |
| 312 | 313 | |
| 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 | ||
| 313 | 356 | it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { |
| 314 | 357 | assert.equal( |
| 315 | 358 | 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( |
| 257 | 257 | const makeRemoteRefreshLoop = ( |
| 258 | 258 | cwd: string, |
| 259 | 259 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, |
| 260 | refreshImmediately: boolean, | |
| 260 | 261 | ) => { |
| 261 | 262 | return Effect.gen(function* () { |
| 262 | 263 | const consecutiveFailuresRef = yield* Ref.make(0); |
| ... | ... | @@ -289,6 +290,15 @@ export const layer = Layer.effect( |
| 289 | 290 | return nextDelay; |
| 290 | 291 | }); |
| 291 | 292 | |
| 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 | ||
| 292 | 302 | return yield* refreshRemoteStatusIfEnabled.pipe( |
| 293 | 303 | Effect.repeat( |
| 294 | 304 | Schedule.identity<Duration.Duration>().pipe( |
| ... | ... | @@ -303,6 +313,7 @@ export const layer = Layer.effect( |
| 303 | 313 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( |
| 304 | 314 | cwd: string, |
| 305 | 315 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, |
| 316 | refreshImmediately: boolean, | |
| 306 | 317 | ) { |
| 307 | 318 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { |
| 308 | 319 | const existing = activePollers.get(cwd); |
| ... | ... | @@ -315,7 +326,7 @@ export const layer = Layer.effect( |
| 315 | 326 | return Effect.succeed([undefined, nextPollers] as const); |
| 316 | 327 | } |
| 317 | 328 | |
| 318 | return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe( | |
| 329 | return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( | |
| 319 | 330 | Effect.forkIn(broadcasterScope), |
| 320 | 331 | Effect.map((fiber) => { |
| 321 | 332 | const nextPollers = new Map(activePollers); |
| ... | ... | @@ -363,11 +374,13 @@ export const layer = Layer.effect( |
| 363 | 374 | const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); |
| 364 | 375 | const subscription = yield* PubSub.subscribe(changesPubSub); |
| 365 | 376 | 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; | |
| 367 | 379 | yield* retainRemotePoller( |
| 368 | 380 | cwd, |
| 369 | 381 | options?.automaticRemoteRefreshInterval ?? |
| 370 | 382 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), |
| 383 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, | |
| 371 | 384 | ); |
| 372 | 385 | |
| 373 | 386 | const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); |