Unified · Split
| @@ -747,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 746 unmodified lines | ||
| 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; |
| @@ -68,6 +68,17 @@ export interface GitStatusDetails { · 67 unmodified lines | ||
| 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 { · 88 unmodified lines | ||
| 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[], |
| @@ -155,6 +155,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 154 unmodified lines | ||
| 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(); |
| @@ -71,6 +71,16 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetail · 70 unmodified lines | ||
| 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* · 1077 unmodified lines | ||
| 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* · 196 unmodified lines | ||
| 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* · 940 unmodified lines | ||
| 2302 | 2394 | status, |
| 2303 | 2395 | statusDetails, |
| 2304 | 2396 | statusDetailsLocal, |
| 2397 | + statusDetailsRemote, | |
| 2305 | 2398 | prepareCommitContext, |
| 2306 | 2399 | commit, |
| 2307 | 2400 | pushCurrentBranch, |
| @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; · 9 unmodified lines | ||
| 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", () => { · 294 unmodified lines | ||
| 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))), |
| @@ -257,6 +257,7 @@ export const layer = Layer.effect( · 256 unmodified lines | ||
| 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( · 26 unmodified lines | ||
| 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( · 8 unmodified lines | ||
| 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( · 6 unmodified lines | ||
| 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( · 41 unmodified lines | ||
| 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); |