diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe..46908940381 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -114,6 +114,33 @@ describe("GitWorkflowService", () => { }).pipe(Effect.provide(testLayer)); }); + it.effect("routes explicit upstream refreshes through the Git driver", () => { + const refreshStatusUpstream = vi.fn(() => Effect.void); + const testLayer = GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: () => + Effect.succeed({ + kind: "git", + } as VcsDriverRegistry.VcsDriverHandle), + }), + ), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + refreshStatusUpstream, + }), + ), + Layer.provide(Layer.mock(GitManager.GitManager)({})), + ); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.refreshStatusUpstream("/repo"); + + assert.deepStrictEqual(refreshStatusUpstream.mock.calls, [["/repo"]]); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("returns an empty ref list when no VCS repository is detected", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..d0adff88b35 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -45,6 +45,7 @@ export class GitWorkflowService extends Context.Service< input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => Effect.Effect; + readonly refreshStatusUpstream: (cwd: string) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -270,6 +271,12 @@ export const make = Effect.gen(function* () { isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null), ), ), + refreshStatusUpstream: (cwd) => + detectGitRepositoryForStatus("GitWorkflowService.refreshStatusUpstream", cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.refreshStatusUpstream(cwd) : Effect.void, + ), + ), invalidateLocalStatus: gitManager.invalidateLocalStatus, invalidateRemoteStatus: gitManager.invalidateRemoteStatus, invalidateStatus: gitManager.invalidateStatus, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..5281775b515 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -199,6 +199,7 @@ export class GitVcsDriver extends Context.Service< cwd: string, options?: GitRemoteStatusOptions, ) => Effect.Effect; + readonly refreshStatusUpstream: (cwd: string) => Effect.Effect; readonly prepareCommitContext: ( cwd: string, filePaths?: readonly string[], diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..01183ecdb82 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -58,6 +58,48 @@ const git = ( return result.stdout.trim(); }); +function withProcessEnvironment( + updates: Readonly>, + effect: Effect.Effect, +): Effect.Effect { + return Effect.suspend(() => { + const previous = new Map(Object.keys(updates).map((key) => [key, process.env[key]] as const)); + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }), + ), + ); + }); +} + +const configureFailingSshUpstream = Effect.fn("configureFailingSshUpstream")(function* (input: { + readonly cwd: string; + readonly scriptDir: string; + readonly initialBranch: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sshWrapperPath = pathService.join(input.scriptDir, "ssh-wrapper.sh"); + yield* fileSystem.writeFileString( + sshWrapperPath, + ["#!/bin/sh", 'printf "leaked\\n" > "$T3_TEST_TMP_PACK_PATH"', "exit 1", ""].join("\n"), + ); + yield* fileSystem.chmod(sshWrapperPath, 0o755); + yield* git(input.cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); + yield* git(input.cwd, ["update-ref", `refs/remotes/origin/${input.initialBranch}`, "HEAD"]); + yield* git(input.cwd, ["branch", "--set-upstream-to", `origin/${input.initialBranch}`]); + return sshWrapperPath; +}); + const initRepoWithCommit = ( cwd: string, ): Effect.Effect< @@ -461,6 +503,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("removes newly-created temporary packs after a failed upstream refresh", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const scriptDir = yield* makeTmpDir("git-vcs-driver-failed-fetch-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const gitCommonDir = pathService.join(cwd, ".git"); + const packDir = pathService.join(gitCommonDir, "objects", "pack"); + const existingTemporaryPack = pathService.join(packDir, "tmp_pack_existing"); + const leakedTemporaryPack = pathService.join(packDir, "tmp_pack_new"); + + yield* fileSystem.makeDirectory(packDir, { recursive: true }); + yield* fileSystem.writeFileString(existingTemporaryPack, "existing\n"); + const sshWrapperPath = yield* configureFailingSshUpstream({ + cwd, + scriptDir, + initialBranch, + }); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const error = yield* withProcessEnvironment( + { + GIT_SSH: sshWrapperPath, + GIT_SSH_COMMAND: undefined, + T3_TEST_TMP_PACK_PATH: leakedTemporaryPack, + }, + driver.refreshStatusUpstream(cwd).pipe(Effect.flip), + ); + + assert.equal(error.operation, "GitVcsDriver.fetchRemoteForStatus"); + assert.isTrue(yield* fileSystem.exists(existingTemporaryPack)); + assert.isFalse(yield* fileSystem.exists(leakedTemporaryPack)); + }), + ); + + it.effect("preserves new temporary packs while a linked worktree fetch lock is active", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const scriptDir = yield* makeTmpDir("git-vcs-driver-locked-fetch-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const gitCommonDir = pathService.join(cwd, ".git"); + const packDir = pathService.join(gitCommonDir, "objects", "pack"); + const leakedTemporaryPack = pathService.join(packDir, "tmp_pack_new"); + const linkedWorktreeLock = pathService.join( + gitCommonDir, + "worktrees", + "other", + "FETCH_HEAD.lock", + ); + + yield* fileSystem.makeDirectory(packDir, { recursive: true }); + yield* fileSystem.makeDirectory(pathService.dirname(linkedWorktreeLock), { + recursive: true, + }); + yield* fileSystem.writeFileString(linkedWorktreeLock, "locked\n"); + const sshWrapperPath = yield* configureFailingSshUpstream({ + cwd, + scriptDir, + initialBranch, + }); + + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* withProcessEnvironment( + { + GIT_SSH: sshWrapperPath, + GIT_SSH_COMMAND: undefined, + T3_TEST_TMP_PACK_PATH: leakedTemporaryPack, + }, + driver.refreshStatusUpstream(cwd).pipe(Effect.flip), + ); + + assert.isTrue(yield* fileSystem.exists(leakedTemporaryPack)); + }), + ); + it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..77e94270632 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -48,10 +48,11 @@ const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); -const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.minutes(1); -const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const TEMPORARY_PACK_FILE_PREFIX = "tmp_pack_"; const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -920,23 +921,149 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemoteForStatus = ( + const readTemporaryPackFiles = Effect.fn("GitVcsDriver.readTemporaryPackFiles")(function* ( + gitCommonDir: string, + ): Effect.fn.Return | null> { + const packDir = path.join(gitCommonDir, "objects", "pack"); + const entries = yield* fileSystem.readDirectory(packDir, { recursive: false }).pipe( + Effect.map((entries) => entries as ReadonlyArray), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed([] as ReadonlyArray) + : Effect.logWarning("Unable to inspect temporary Git pack files", { + reason: error.reason._tag, + }).pipe(Effect.as(null)), + }), + ); + if (entries === null) { + return null; + } + return new Set(entries.filter((entry) => entry.startsWith(TEMPORARY_PACK_FILE_PREFIX))); + }); + + const listFetchHeadLockPaths = Effect.fn("GitVcsDriver.listFetchHeadLockPaths")(function* ( + gitCommonDir: string, + ): Effect.fn.Return | null> { + const worktreesDir = path.join(gitCommonDir, "worktrees"); + const worktreeEntries = yield* fileSystem + .readDirectory(worktreesDir, { recursive: false }) + .pipe( + Effect.map((entries) => entries as ReadonlyArray), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed([] as ReadonlyArray) + : Effect.logWarning("Unable to inspect linked worktree fetch locks", { + reason: error.reason._tag, + }).pipe(Effect.as(null)), + }), + ); + if (worktreeEntries === null) { + return null; + } + return [ + path.join(gitCommonDir, "FETCH_HEAD.lock"), + ...worktreeEntries.map((entry) => path.join(worktreesDir, entry, "FETCH_HEAD.lock")), + ]; + }); + + const hasActiveFetchLock = Effect.fn("GitVcsDriver.hasActiveFetchLock")(function* ( + gitCommonDir: string, + ): Effect.fn.Return { + const lockPaths = yield* listFetchHeadLockPaths(gitCommonDir); + if (lockPaths === null) { + return true; + } + for (const lockPath of lockPaths) { + const exists = yield* fileSystem.exists(lockPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.logWarning("Unable to inspect a Git fetch lock", { + reason: error.reason._tag, + }).pipe(Effect.as(true)), + }), + ); + if (exists) { + return true; + } + } + return false; + }); + + const removeNewTemporaryPackFiles = Effect.fn("GitVcsDriver.removeNewTemporaryPackFiles")( + function* (gitCommonDir: string, filesBeforeFetch: ReadonlySet | null) { + if (filesBeforeFetch === null) { + return; + } + const filesAfterFetch = yield* readTemporaryPackFiles(gitCommonDir); + if (filesAfterFetch === null) { + return; + } + const newFiles = [...filesAfterFetch].filter((entry) => !filesBeforeFetch.has(entry)); + if (newFiles.length === 0) { + return; + } + if (yield* hasActiveFetchLock(gitCommonDir)) { + yield* Effect.logWarning( + "Skipped temporary Git pack cleanup while a fetch lock is active", + { + temporaryPackCount: newFiles.length, + }, + ); + return; + } + + const packDir = path.join(gitCommonDir, "objects", "pack"); + const removalResults = yield* Effect.forEach( + newFiles, + (entry) => + fileSystem.remove(path.join(packDir, entry), { force: true }).pipe( + Effect.as(true), + Effect.catchTags({ + PlatformError: () => Effect.succeed(false), + }), + ), + { concurrency: 1 }, + ); + const removedCount = removalResults.filter(Boolean).length; + if (removedCount > 0) { + yield* Effect.logWarning("Removed temporary Git pack files after a failed fetch", { + temporaryPackCount: removedCount, + }); + } + if (removedCount < newFiles.length) { + yield* Effect.logWarning("Failed to remove some temporary Git pack files", { + temporaryPackCount: newFiles.length - removedCount, + }); + } + }, + ); + + const fetchRemoteForStatus = Effect.fn("GitVcsDriver.fetchRemoteForStatus")(function* ( gitCommonDir: string, remoteName: string, - ): Effect.Effect => { + ) { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return executeGit( + const temporaryPackFilesBeforeFetch = yield* readTemporaryPackFiles(gitCommonDir); + yield* executeGit( "GitVcsDriver.fetchRemoteForStatus", fetchCwd, ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { - allowNonZeroExit: true, env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: "Background Git fetch failed.", timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), }, - ).pipe(Effect.asVoid); - }; + ).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : removeNewTemporaryPackFiles(gitCommonDir, temporaryPackFilesBeforeFetch), + ), + ); + }); const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ @@ -977,6 +1104,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + const refreshStatusUpstream: GitVcsDriver.GitVcsDriver["Service"]["refreshStatusUpstream"] = + Effect.fn("refreshStatusUpstream")(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd); + }); + const resolveDefaultBranchName = ( cwd: string, remoteName: string, @@ -2535,6 +2667,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetails, statusDetailsLocal, statusDetailsRemote, + refreshStatusUpstream, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 629f832ad42..2ecd78e3f49 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -74,6 +74,7 @@ function makeTestLayer(state: { remoteStatusCalls: number; localInvalidationCalls: number; remoteInvalidationCalls: number; + upstreamRefreshCalls?: number; remoteStatusRefreshUpstreamValues?: Array; }) { return VcsStatusBroadcaster.layer.pipe( @@ -92,6 +93,12 @@ function makeTestLayer(state: { state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); return state.currentRemoteStatus; }), + refreshStatusUpstream: () => + Effect.sync(() => { + if (state.upstreamRefreshCalls !== undefined) { + state.upstreamRefreshCalls += 1; + } + }), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -593,6 +600,7 @@ describe("VcsStatusBroadcaster", () => { remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + upstreamRefreshCalls: 0, }; return Effect.gen(function* () { @@ -614,6 +622,7 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(snapshotDeferred); assert.equal(state.remoteStatusCalls, 1); assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.upstreamRefreshCalls, 0); yield* TestClock.adjust(Duration.seconds(59)); assert.equal(state.remoteStatusCalls, 1); @@ -622,11 +631,106 @@ describe("VcsStatusBroadcaster", () => { yield* Effect.yieldNow; assert.equal(state.remoteStatusCalls, 2); assert.equal(state.remoteInvalidationCalls, 1); + assert.equal(state.upstreamRefreshCalls, 1); yield* Scope.close(scope, Exit.void); }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + it.effect("backs off failed upstream refreshes without discarding cached status", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + upstreamRefreshCalls: 0, + }; + let secondRemoteReadDeferred: Deferred.Deferred | null = null; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }).pipe( + Effect.tap(() => + state.remoteStatusCalls === 2 && secondRemoteReadDeferred + ? Deferred.succeed(secondRemoteReadDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + refreshStatusUpstream: () => + Effect.suspend(() => { + state.upstreamRefreshCalls += 1; + return state.upstreamRefreshCalls === 1 + ? Effect.fail( + new GitManagerError({ + operation: "GitVcsDriver.fetchRemoteForStatus", + cwd: "/repo", + detail: "Background Git fetch failed.", + }), + ) + : Effect.void; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + secondRemoteReadDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, + ), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(snapshotDeferred); + yield* TestClock.adjust(Duration.seconds(1)); + yield* Effect.yieldNow; + assert.equal(state.upstreamRefreshCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + + yield* TestClock.adjust(Duration.seconds(29)); + assert.equal(state.upstreamRefreshCalls, 1); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Deferred.await(secondRemoteReadDeferred); + assert.equal(state.upstreamRefreshCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 1); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer()))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), @@ -679,6 +783,7 @@ describe("VcsStatusBroadcaster", () => { remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + upstreamRefreshCalls: 0, }; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -695,6 +800,10 @@ describe("VcsStatusBroadcaster", () => { state.remoteStatusCalls += 1; return state.currentRemoteStatus; }), + refreshStatusUpstream: () => + Effect.sync(() => { + state.upstreamRefreshCalls += 1; + }), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -719,6 +828,7 @@ describe("VcsStatusBroadcaster", () => { assert.isTrue(Option.isSome(snapshot)); assert.equal(state.remoteStatusCalls, 0); assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.upstreamRefreshCalls, 0); }).pipe(Effect.provide(testLayer)); }); @@ -730,6 +840,7 @@ describe("VcsStatusBroadcaster", () => { remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + upstreamRefreshCalls: 0, }; let remoteInterruptedDeferred: Deferred.Deferred | null = null; let remoteStartedDeferred: Deferred.Deferred | null = null; @@ -746,13 +857,18 @@ describe("VcsStatusBroadcaster", () => { remoteStatus: () => Effect.sync(() => { state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + refreshStatusUpstream: () => + Effect.sync(() => { + state.upstreamRefreshCalls += 1; }).pipe( Effect.andThen( remoteStartedDeferred ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) : Effect.void, ), - Effect.andThen(Effect.never as Effect.Effect), + Effect.andThen(Effect.never), Effect.onInterrupt(() => remoteInterruptedDeferred ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) @@ -797,7 +913,8 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(secondSnapshot); yield* Deferred.await(remoteStarted); - assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.upstreamRefreshCalls, 1); + assert.equal(state.remoteStatusCalls, 0); yield* Scope.close(firstScope, Exit.void); assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 4f1541d5964..ba4c6e424cd 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -358,9 +358,10 @@ export const make = Effect.gen(function* () { options?: { readonly refreshUpstream?: boolean }, ) { if (options?.refreshUpstream !== false) { + yield* workflow.refreshStatusUpstream(cwd); yield* workflow.invalidateRemoteStatus(cwd); } - const remote = yield* workflow.remoteStatus({ cwd }, options); + const remote = yield* workflow.remoteStatus({ cwd }, { refreshUpstream: false }); return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); });