diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 5423f4f1476..92ee9007b3a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -747,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { const details = yield* gitCore - .statusDetails(cwd) + .statusDetailsRemote(cwd) .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); if (details === null || !details.isRepo) { return null; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 465cd21f320..adf991556d4 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -68,6 +68,17 @@ export interface GitStatusDetails { aheadOfDefaultCount: number; } +export interface GitRemoteStatusDetails { + isRepo: boolean; + isDefaultBranch: boolean; + branch: string | null; + upstreamRef: string | null; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; + aheadOfDefaultCount: number; +} + export interface GitPreparedCommitContext { stagedSummary: string; stagedPatch: string; @@ -162,6 +173,9 @@ export interface GitVcsDriverShape { readonly status: (input: VcsStatusInput) => Effect.Effect; readonly statusDetails: (cwd: string) => Effect.Effect; readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly statusDetailsRemote: ( + 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 1909b3b5033..c0e0f1876c4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -155,6 +155,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports remote divergence without reading working-tree details", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/remote-status"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + yield* git(cwd, ["push", "-u", "origin", "feature/remote-status"]); + yield* writeTextFile(cwd, "untracked.txt", "local-only\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, "feature/remote-status"); + assert.equal(status.hasUpstream, true); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + assert.notProperty(status, "workingTree"); + assert.notProperty(status, "hasWorkingTreeChanges"); + }), + ); + + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const origin = yield* makeTmpDir("git-vcs-driver-origin-"); + const upstream = yield* makeTmpDir("git-vcs-driver-upstream-"); + yield* initRepoWithCommit(cwd); + yield* git(origin, ["init", "--bare"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["remote", "add", "origin", origin]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["push", "upstream", "main"]); + yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]); + yield* git(cwd, ["checkout", "-b", "release"]); + yield* writeTextFile(cwd, "release.txt", "release\n"); + yield* git(cwd, ["add", "release.txt"]); + yield* git(cwd, ["commit", "-m", "release commit"]); + yield* git(cwd, ["push", "-u", "upstream", "release"]); + yield* git(cwd, [ + "symbolic-ref", + "refs/remotes/upstream/HEAD", + "refs/remotes/upstream/release", + ]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(status.branch, "release"); + assert.equal(status.upstreamRef, "upstream/release"); + assert.equal(status.isDefaultBranch, false); + }), + ); + it.effect("disables SSH askpass for background upstream status fetches", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 240c20336c6..42fda3c80b2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -71,6 +71,16 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ + isRepo: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, +}); type TraceTailState = { processedChars: number; @@ -1154,6 +1164,78 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; }); + const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { + const branchResult = yield* executeGit( + "GitVcsDriver.statusDetailsRemote.branch", + cwd, + ["rev-parse", "--abbrev-ref", "HEAD"], + { allowNonZeroExit: true }, + ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + + if (branchResult === null) { + return NON_REPOSITORY_REMOTE_STATUS_DETAILS; + } + if (branchResult.exitCode !== 0) { + const stderr = branchResult.stderr.trim(); + return yield* createGitCommandError( + "GitVcsDriver.statusDetailsRemote.branch", + cwd, + ["rev-parse", "--abbrev-ref", "HEAD"], + stderr || "git branch lookup failed", + ); + } + + const branchValue = branchResult.stdout.trim(); + const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + const upstream = yield* resolveCurrentUpstream(cwd); + const upstreamRef = upstream?.upstreamRef ?? null; + let aheadCount = 0; + let behindCount = 0; + + if (upstreamRef) { + const divergence = yield* executeGit( + "GitVcsDriver.statusDetailsRemote.divergence", + cwd, + ["rev-list", "--left-right", "--count", `HEAD...${upstreamRef}`], + { allowNonZeroExit: true }, + ); + if (divergence.exitCode === 0) { + const [aheadRaw, behindRaw] = divergence.stdout.trim().split(/\s+/); + const parsedAhead = Number.parseInt(aheadRaw ?? "0", 10); + const parsedBehind = Number.parseInt(behindRaw ?? "0", 10); + aheadCount = Number.isFinite(parsedAhead) ? Math.max(0, parsedAhead) : 0; + behindCount = Number.isFinite(parsedBehind) ? Math.max(0, parsedBehind) : 0; + } + } else if (branch) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + Effect.orElseSucceed(() => 0), + ); + } + + const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin"); + const isDefaultBranch = + branch !== null && + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))); + const aheadOfDefaultCount = + branch && !isDefaultBranch + ? upstreamRef === null + ? aheadCount + : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) + : 0; + + return { + isRepo: true, + isDefaultBranch, + branch, + upstreamRef, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + aheadOfDefaultCount, + }; + }); + const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { const branchRecency = yield* executeGit( "GitVcsDriver.readBranchRecency", @@ -1356,6 +1438,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn( + "statusDetailsRemote", + )(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + return yield* readStatusDetailsRemote(cwd); + }); + const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ @@ -2302,6 +2394,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* status, statusDetails, statusDetailsLocal, + statusDetailsRemote, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index d47dda29bf0..e92ab374f4e 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import type { VcsStatusLocalResult, VcsStatusRemoteResult, @@ -310,6 +311,48 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("delays automatic refresh when a cached remote snapshot is available", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(snapshotDeferred); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + + yield* TestClock.adjust(Duration.seconds(59)); + assert.equal(state.remoteStatusCalls, 1); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Effect.yieldNow; + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 1); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 40cdcf2c809..9cd5a6c337c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -257,6 +257,7 @@ export const layer = Layer.effect( const makeRemoteRefreshLoop = ( cwd: string, automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, ) => { return Effect.gen(function* () { const consecutiveFailuresRef = yield* Ref.make(0); @@ -289,6 +290,15 @@ export const layer = Layer.effect( return nextDelay; }); + if (!refreshImmediately) { + const configuredInterval = yield* automaticRemoteRefreshInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval, + ); + } + return yield* refreshRemoteStatusIfEnabled.pipe( Effect.repeat( Schedule.identity().pipe( @@ -303,6 +313,7 @@ export const layer = Layer.effect( const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( cwd: string, automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, ) { yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); @@ -315,7 +326,7 @@ export const layer = Layer.effect( return Effect.succeed([undefined, nextPollers] as const); } - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe( + return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); @@ -363,11 +374,13 @@ export const layer = Layer.effect( const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); - const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; + const cachedStatus = yield* getCachedStatus(cwd); + const initialRemote = cachedStatus?.remote?.value ?? null; yield* retainRemotePoller( cwd, options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);