From edd9a8fc29be99440d7bd885a367c55bc1736c2f Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:47:38 +0000 Subject: [PATCH] fix(server): stop background status fetches after repeated timeouts Background upstream refresh retried git fetch forever after the 5s timeout, so a macOS keychain prompt from git-credential-osxkeychain kept returning. After three consecutive timeouts, polling that remote stops. Pull stays available when the cached branch is not behind, and a successful pull, push, or fetch resumes background refresh. Fixes #13912 --- .../features/threads/ThreadGitControls.tsx | 7 + .../features/threads/git/GitOverviewSheet.tsx | 19 + .../threads/git/gitSheetComponents.tsx | 8 + apps/server/src/git/GitManager.ts | 5 + apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 263 +++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 324 ++++++++++++++++-- .../GitActionsControl.logic.test.ts | 15 + .../src/components/GitActionsControl.logic.ts | 14 + apps/web/src/components/GitActionsControl.tsx | 125 +++++-- docs/user/source-control.md | 5 + .../src/state/gitActions.test.ts | 48 +++ .../client-runtime/src/state/gitActions.ts | 46 +++ packages/contracts/src/git.ts | 5 + packages/shared/src/git.test.ts | 35 ++ packages/shared/src/git.ts | 5 + 16 files changed, 853 insertions(+), 76 deletions(-) create mode 100644 packages/client-runtime/src/state/gitActions.test.ts diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index e3a4033cd809..6dd405c596d0 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -39,6 +39,10 @@ function compactMenuBranchLabel(branch: string): string { return truncateMiddle(branch, 24); } +/** + * Short git status for the thread header. Mentions a stopped upstream refresh + * so the overview, which can pull, is reachable from that label. + */ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { if (!gitStatus) { return "Checking status"; @@ -62,6 +66,9 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { if (gitStatus.pr?.state === "open") { parts.push(`PR #${gitStatus.pr.number}`); } + if (gitStatus.upstreamNeedsAttention) { + parts.push("upstream needs attention"); + } return parts.join(" · "); } diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 831ef1145cec..4dcab4817955 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -2,7 +2,9 @@ import { type GitActionRequestInput, buildMenuItems, getGitActionDisabledReason, + offersUpstreamAttentionPull, requiresDefaultBranchConfirmation, + upstreamStatusAttentionMessage, } from "@t3tools/client-runtime/state/vcs"; import { resolveThreadPullRequestChains, @@ -54,6 +56,10 @@ type GitOverviewSheetProps = StaticScreenProps<{ readonly presentation?: "sheet" | "inspector"; }; +/** + * Git overview for a thread. Offers Pull when the branch is behind, and also + * when background upstream refresh has stopped on a cache that is not behind. + */ export function GitOverviewSheet(props: GitOverviewSheetProps) { const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); @@ -217,6 +223,8 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ); const behindCount = gitStatus.data?.behindCount ?? 0; + const upstreamAttention = upstreamStatusAttentionMessage(gitStatus.data); + const showUpstreamAttentionPull = offersUpstreamAttentionPull(gitStatus.data); // Deterministic pull-to-refresh state. Tying RefreshControl to the query's // isPending flag left the spinner stuck (the status query reports pending @@ -277,6 +285,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { onPress={() => void gitActions.onPullSelectedThreadBranch()} /> + ) : showUpstreamAttentionPull ? ( + <> + {Platform.OS !== "android" ? : null} + void gitActions.onPullSelectedThreadBranch()} + /> + ) : null} {Platform.OS !== "android" ? : null} + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetchAttempts = yield* Ref.make(0); + const hangStatusFetches = yield* Ref.make(true); + const fetchStartedRef = yield* Ref.make(yield* Deferred.make()); + const statusFetches: Array<{ + readonly args: ReadonlyArray; + readonly env: Record | undefined; + }> = []; + const pullCommands: Array<{ + readonly args: ReadonlyArray; + readonly env: Record | undefined; + }> = []; + const warnings: string[] = []; + const logger = Logger.make(({ message }) => { + warnings.push(String(message)); + }); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isStatusFetch = + command.args.includes("fetch") && command.args.includes("--no-auto-gc"); + if (isStatusFetch) { + statusFetches.push({ args: command.args, env: command.options.env }); + yield* Ref.update(fetchAttempts, (count) => count + 1); + if (yield* Ref.get(hangStatusFetches)) { + yield* Deferred.succeed(yield* Ref.get(fetchStartedRef), undefined).pipe(Effect.ignore); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: Effect.never, + }); + } + } + if (command.args[0] === "pull") { + pullCommands.push({ args: command.args, env: command.options.env }); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const runGit = (workingDirectory: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.upstreamRefreshStop", + cwd: workingDirectory, + args, + timeoutMs: 10_000, + }); + yield* driver.initRepo({ cwd }); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); + yield* runGit(remote, ["init", "--bare"]); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); + yield* Ref.set(fetchAttempts, 0); + statusFetches.length = 0; + + const withLogger = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); + const readStatus = () => withLogger(driver.statusDetailsRemote(cwd)); + /** + * Reads status while the status fetch is hung, then advances the test clock + * until that fetch hits the status timeout. + */ + const readUntilFetchTimeout = () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + yield* Ref.set(fetchStartedRef, started); + const fiber = yield* readStatus().pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + // The timeout is armed when the hung fetch is spawned. A second tick covers + // the case where that registration lands after the first adjustment. + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("5 seconds"); + return yield* Fiber.join(fiber); + }); + + const previousEnv = { + GCM_INTERACTIVE: process.env.GCM_INTERACTIVE, + GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT, + }; + process.env.GCM_INTERACTIVE = "always"; + process.env.GIT_TERMINAL_PROMPT = "1"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const key of ["GCM_INTERACTIVE", "GIT_TERMINAL_PROMPT"] as const) { + const previous = previousEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }), + ); + + return { + driver, + cwd, + fetchAttempts, + hangStatusFetches, + warnings, + statusFetches, + pullCommands, + readStatus, + readUntilFetchTimeout, + }; + }); + +it.effect( + "stops background upstream refresh after repeated timeouts without clearing credential helpers", + () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeUpstreamRefreshFixture(); + + const first = yield* fixture.readUntilFetchTimeout(); + assert.equal(first.upstreamNeedsAttention, false); + yield* TestClock.adjust("31 seconds"); + const second = yield* fixture.readUntilFetchTimeout(); + assert.equal(second.upstreamNeedsAttention, false); + yield* TestClock.adjust("61 seconds"); + const third = yield* fixture.readUntilFetchTimeout(); + + assert.equal(third.upstreamNeedsAttention, true); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 3); + assert.equal( + fixture.warnings.filter((warning) => warning.includes("upstream status needs attention")) + .length, + 1, + ); + const statusFetch = fixture.statusFetches[0]; + assert.ok(statusFetch); + assert.equal(statusFetch.args[0], "--git-dir"); + assert.deepEqual(statusFetch.args.slice(-5), [ + "fetch", + "--quiet", + "--no-tags", + "--no-auto-gc", + "origin", + ]); + assert.isFalse(statusFetch.args.includes("-c")); + assert.isFalse(statusFetch.args.some((arg) => arg.includes("credential.helper"))); + assert.equal(statusFetch.env?.GCM_INTERACTIVE, "never"); + assert.equal(statusFetch.env?.GIT_ASKPASS, ""); + assert.equal(statusFetch.env?.GIT_TERMINAL_PROMPT, "0"); + assert.equal(statusFetch.env?.SSH_ASKPASS, ""); + assert.equal(statusFetch.env?.SSH_ASKPASS_REQUIRE, "never"); + assert.deepEqual( + Object.keys(statusFetch.env ?? {}).filter( + (key) => key.startsWith("GIT_CONFIG_KEY_") && process.env[key] === undefined, + ), + [], + ); + + yield* TestClock.adjust("15 minutes"); + const paused = yield* fixture.readStatus(); + assert.equal(paused.upstreamNeedsAttention, true); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 3); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("resets the background fetch timeout streak after a successful fetch", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeUpstreamRefreshFixture(); + + yield* fixture.readUntilFetchTimeout(); + yield* TestClock.adjust("31 seconds"); + yield* fixture.readUntilFetchTimeout(); + yield* TestClock.adjust("61 seconds"); + yield* Ref.set(fixture.hangStatusFetches, false); + + const recovered = yield* fixture.readStatus(); + assert.equal(recovered.upstreamNeedsAttention, false); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 3); + + yield* TestClock.adjust("16 seconds"); + yield* Ref.set(fixture.hangStatusFetches, true); + const afterReset = yield* fixture.readUntilFetchTimeout(); + assert.equal(afterReset.upstreamNeedsAttention, false); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 4); + + yield* TestClock.adjust("31 seconds"); + const secondAfterReset = yield* fixture.readUntilFetchTimeout(); + assert.equal(secondAfterReset.upstreamNeedsAttention, false); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 5); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("resumes background upstream refresh after an explicit pull", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeUpstreamRefreshFixture(); + yield* fixture.readUntilFetchTimeout(); + yield* TestClock.adjust("31 seconds"); + yield* fixture.readUntilFetchTimeout(); + yield* TestClock.adjust("61 seconds"); + yield* fixture.readUntilFetchTimeout(); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 3); + + yield* Ref.set(fixture.hangStatusFetches, false); + const pulled = yield* fixture.driver + .pullCurrentBranch(fixture.cwd) + .pipe(Effect.provideService(Logger.CurrentLoggers, new Set())); + assert.equal(pulled.status, "skipped_up_to_date"); + assert.equal(fixture.pullCommands.length, 1); + assert.deepEqual(fixture.pullCommands[0]?.args, ["pull", "--ff-only"]); + assert.equal(fixture.pullCommands[0]?.env?.GIT_TERMINAL_PROMPT, "1"); + assert.equal(fixture.pullCommands[0]?.env?.GCM_INTERACTIVE, "always"); + assert.isFalse( + fixture.pullCommands[0]?.args.some((arg) => arg.includes("credential.helper")), + ); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 3); + + const recovered = yield* fixture.readStatus(); + assert.equal(recovered.upstreamNeedsAttention, false); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 4); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("resumes background upstream refresh after an explicit fetch", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeUpstreamRefreshFixture(); + yield* fixture.readUntilFetchTimeout(); + yield* TestClock.adjust("31 seconds"); + yield* fixture.readUntilFetchTimeout(); + yield* TestClock.adjust("61 seconds"); + yield* fixture.readUntilFetchTimeout(); + + yield* Ref.set(fixture.hangStatusFetches, false); + yield* fixture.driver.fetchRemote({ cwd: fixture.cwd, remoteName: "origin" }); + const recovered = yield* fixture.readStatus(); + assert.equal(recovered.upstreamNeedsAttention, false); + assert.equal(yield* Ref.get(fixture.fetchAttempts), 4); }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0dd73af687f9..babacc7014bb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -64,6 +64,11 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const GIT_COMMAND_TIMED_OUT_DETAIL = "Git command timed out."; +// One slow fetch is not enough to stop. Three consecutive kills at the status +// timeout match a credential helper blocked on a GUI prompt: each attempt can +// raise a macOS keychain dialog and ends before Always Allow can be stored. +const STATUS_UPSTREAM_REFRESH_TIMEOUT_STOP_AFTER = 3; const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); @@ -78,6 +83,10 @@ const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); +// Suppress Git Credential Manager, terminal prompts, and OpenSSH askpass. +// credential.helper stays intact so a silent osxkeychain or `gh auth git-credential` +// lookup can still succeed. A helper that blocks on a GUI is stopped after +// repeated timeouts instead of being disabled on every fetch. const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -110,6 +119,7 @@ const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze {} +/** + * Backs off a failed background upstream fetch. The first failure waits 30s and + * each later failure doubles that, capped at 15 minutes. + */ function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const cooldownMs = @@ -129,6 +143,14 @@ function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Dura return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); } +/** + * Distinguishes a killed background fetch from a fast Git error. + * Timeouts are what leave `git-credential-osxkeychain` blocked on a password dialog. + */ +function isStatusUpstreamRefreshTimeout(error: GitCommandError): boolean { + return error.detail === GIT_COMMAND_TIMED_OUT_DETAIL; +} + class GitRefsSnapshotCacheKey extends Data.Class<{ gitCommonDir: string; epoch: number; @@ -824,6 +846,10 @@ const collectOutput = Effect.fnUntraced(function* ( }; }); +/** + * Builds the Git driver. Background upstream refresh stops after repeated + * timeouts and resumes after an explicit pull, push, or fetch. + */ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -831,6 +857,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + /** + * Spawns Git for driver operations. Callers pass a timeout and env; a timeout + * fails with the detail background status refresh uses to detect a hung prompt. + */ const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { const commandInput = { @@ -841,6 +871,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const appendTruncationMarker = input.appendTruncationMarker ?? false; + /** + * Runs one Git process and fails with `GIT_COMMAND_TIMED_OUT_DETAIL` when + * the command outlives its timeout. Background status fetches use that + * detail to tell a hung credential prompt from other Git failures. + */ const runGitCommand = Effect.fn("runGitCommand")(function* () { const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( Effect.provideService(Path.Path, path), @@ -953,7 +988,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () => new GitCommandError({ ...gitCommandContext(commandInput), - detail: "Git command timed out.", + detail: GIT_COMMAND_TIMED_OUT_DETAIL, }), ), ), @@ -1330,30 +1365,138 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); }); - const statusRemoteRefreshFailureCounts = new Map(); + interface StatusRemoteRefreshFailure { + readonly consecutiveFailures: number; + readonly consecutiveTimeouts: number; + } + const statusRemoteRefreshFailures = new Map(); + // Bumped when an explicit pull, push, or fetch resumes refresh, so a background + // timeout that finishes later cannot put the remote back into the stopped state. + const statusRemoteRefreshGeneration = new Map(); + /** + * Keys failure state by repository and remote. Linked worktrees share a Git + * common directory, so they share one backoff. + */ const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; - const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { - const key = statusRemoteRefreshFailureKey(cacheKey); - const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; - statusRemoteRefreshFailureCounts.delete(key); - statusRemoteRefreshFailureCounts.set(key, nextCount); - if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { - const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value; + /** + * Remembers the latest failure for a remote and drops the oldest entry once + * the map exceeds the status-refresh cache capacity. + */ + const rememberStatusRemoteRefreshFailure = (key: string, failure: StatusRemoteRefreshFailure) => { + statusRemoteRefreshFailures.delete(key); + statusRemoteRefreshFailures.set(key, failure); + if (statusRemoteRefreshFailures.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { + const oldestKey = statusRemoteRefreshFailures.keys().next().value; if (oldestKey !== undefined) { - statusRemoteRefreshFailureCounts.delete(oldestKey); + statusRemoteRefreshFailures.delete(oldestKey); + statusRemoteRefreshGeneration.delete(oldestKey); } } }; + /** + * Returns the recovery generation for a remote. A late fetch compares against + * the generation it started with and ignores itself after recovery. + */ + const currentStatusRemoteRefreshGeneration = (key: string) => + statusRemoteRefreshGeneration.get(key) ?? 0; + /** + * Counts a finished background fetch failure. A timeout continues the streak + * that stops polling; any other failure clears that streak and keeps the + * existing backoff. Returns null when recovery already superseded this attempt. + */ + const recordStatusRemoteRefreshFailure = ( + cacheKey: StatusRemoteRefreshCacheKey, + error: GitCommandError, + generation: number, + ) => { + const key = statusRemoteRefreshFailureKey(cacheKey); + if (currentStatusRemoteRefreshGeneration(key) !== generation) { + return null; + } + const previous = statusRemoteRefreshFailures.get(key); + const consecutiveTimeouts = isStatusUpstreamRefreshTimeout(error) + ? (previous?.consecutiveTimeouts ?? 0) + 1 + : 0; + rememberStatusRemoteRefreshFailure(key, { + consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1, + consecutiveTimeouts, + }); + return consecutiveTimeouts; + }; + /** + * Forgets backoff for a remote after a fetch that should resume polling. + */ const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => { - statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey)); + statusRemoteRefreshFailures.delete(statusRemoteRefreshFailureKey(cacheKey)); + }; + /** + * Clears backoff and invalidates in-flight failure accounting for one remote. + */ + const bumpStatusRemoteRefreshGeneration = (cacheKey: StatusRemoteRefreshCacheKey) => { + const key = statusRemoteRefreshFailureKey(cacheKey); + statusRemoteRefreshGeneration.set(key, currentStatusRemoteRefreshGeneration(key) + 1); + clearStatusRemoteRefreshFailures(cacheKey); }; + /** + * Reports whether background polling has stopped for this remote. + */ + const upstreamStatusNeedsAttention = (cacheKey: StatusRemoteRefreshCacheKey) => + (statusRemoteRefreshFailures.get(statusRemoteRefreshFailureKey(cacheKey)) + ?.consecutiveTimeouts ?? 0) >= STATUS_UPSTREAM_REFRESH_TIMEOUT_STOP_AFTER; + /** + * How long a cached background fetch stays fresh. Repeated timeouts do not + * expire, so a credential helper blocked on a GUI prompt is not spawned again. + */ + const statusRemoteRefreshCacheTtl = ( + exit: Exit.Exit, + cacheKey: StatusRemoteRefreshCacheKey, + ) => { + if (Exit.isSuccess(exit)) return STATUS_UPSTREAM_REFRESH_INTERVAL; + const failure = statusRemoteRefreshFailures.get(statusRemoteRefreshFailureKey(cacheKey)); + if ((failure?.consecutiveTimeouts ?? 0) >= STATUS_UPSTREAM_REFRESH_TIMEOUT_STOP_AFTER) { + return Duration.infinity; + } + return statusUpstreamRefreshFailureCooldown(failure?.consecutiveFailures ?? 1); + }; + /** + * Fetches one remote for status. A success clears its backoff. The timeout + * that reaches the stop threshold is logged once. + */ const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( cacheKey: StatusRemoteRefreshCacheKey, ) { + const generation = currentStatusRemoteRefreshGeneration( + statusRemoteRefreshFailureKey(cacheKey), + ); return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( - Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), - Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), + Effect.tap(() => + Effect.sync(() => { + if ( + currentStatusRemoteRefreshGeneration(statusRemoteRefreshFailureKey(cacheKey)) !== + generation + ) { + return; + } + clearStatusRemoteRefreshFailures(cacheKey); + }), + ), + Effect.tapError((error) => + Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey, error, generation)).pipe( + Effect.flatMap((consecutiveTimeouts) => + consecutiveTimeouts === STATUS_UPSTREAM_REFRESH_TIMEOUT_STOP_AFTER + ? Effect.logWarning( + "Stopped background Git fetch after repeated timeouts; upstream status needs attention", + ).pipe( + Effect.annotateLogs({ + remoteName: cacheKey.remoteName, + consecutiveTimeouts, + }), + ) + : Effect.void, + ), + ), + ), Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), Effect.as(true as const), ); @@ -1361,32 +1504,59 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - // A failed background fetch is intentionally cached and exponentially - // backed off. Status reads swallow this failure and use the last fetched - // refs, so repeated thread mounts cannot turn a slow or unavailable remote - // into a repository-wide Git subprocess storm. - timeToLive: (exit, cacheKey) => - Exit.isSuccess(exit) - ? STATUS_UPSTREAM_REFRESH_INTERVAL - : statusUpstreamRefreshFailureCooldown( - statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1, - ), + // A failed background fetch is cached and exponentially backed off. After + // repeated timeouts the entry does not expire, so a credential helper that + // blocks on a GUI prompt is not spawned again. Status reads keep the last + // fetched refs. A successful explicit pull, push, or fetch clears that stop. + timeToLive: statusRemoteRefreshCacheTtl, }); + /** + * Lets background refresh poll a remote again after an explicit fetch of it. + */ + const releaseStatusUpstreamRefresh = Effect.fn("releaseStatusUpstreamRefresh")(function* ( + cwd: string, + remoteName: string, + ) { + const gitCommonDir = yield* resolveGitCommonDir(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + if (gitCommonDir === null) return; + const cacheKey = new StatusRemoteRefreshCacheKey({ gitCommonDir, remoteName }); + bumpStatusRemoteRefreshGeneration(cacheKey); + yield* Cache.invalidate(statusRemoteRefreshCache, cacheKey); + }); + + /** + * Lets background refresh poll the current branch's upstream after pull or push. + */ + const releaseTrackedUpstreamStatusRefresh = Effect.fn("releaseTrackedUpstreamStatusRefresh")( + function* (cwd: string) { + const upstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + if (!upstream) return; + yield* releaseStatusUpstreamRefresh(cwd, upstream.remoteName); + }, + ); + + /** + * Refreshes upstream refs when the cached fetch is stale. A remote that has + * already stopped on repeated timeouts is left alone. + */ const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( cwd: string, ) { const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; const gitCommonDir = yield* resolveGitCommonDir(cwd); + const cacheKey = new StatusRemoteRefreshCacheKey({ + gitCommonDir, + remoteName: upstream.remoteName, + }); + if (upstreamStatusNeedsAttention(cacheKey)) return; // The cache loader logs failed attempts; cache hits keep using the last fetched refs. - yield* Cache.get( - statusRemoteRefreshCache, - new StatusRemoteRefreshCacheKey({ - gitCommonDir, - remoteName: upstream.remoteName, - }), - ).pipe(Effect.ignore); + yield* Cache.get(statusRemoteRefreshCache, cacheKey).pipe(Effect.ignore); }); const resolveDefaultBranchName = ( @@ -1598,6 +1768,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; }); + /** + * Reads ahead/behind for the current upstream without scanning the worktree. + * Includes whether background refresh has stopped after repeated timeouts. + */ const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", @@ -1679,6 +1853,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? aheadCount : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) : 0; + const upstreamNeedsAttention = + upstream === null + ? false + : yield* resolveGitCommonDir(cwd).pipe( + Effect.map((gitCommonDir) => + upstreamStatusNeedsAttention( + new StatusRemoteRefreshCacheKey({ + gitCommonDir, + remoteName: upstream.remoteName, + }), + ), + ), + Effect.catchTags({ GitCommandError: () => Effect.succeed(false) }), + ); return { isRepo: true, @@ -1690,6 +1878,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* aheadCount, behindCount, aheadOfDefaultCount, + upstreamNeedsAttention, }; }); @@ -3665,6 +3854,69 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + /** + * Resumes background upstream refresh when a push updated the remote. + * A skipped push did not replace remote refs, so polling stays where it was. + */ + const resumeUpstreamRefreshIfPushed = (cwd: string, result: GitVcsDriver.GitPushResult) => + result.status === "pushed" ? releaseTrackedUpstreamStatusRefresh(cwd) : Effect.void; + /** + * Pushes the current branch. A completed push lets background status fetch + * that upstream again, which is how a keychain prompt can be approved. + */ + const pushCurrentBranchAndResumeRefresh: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = + (cwd, fallbackBranch, options) => + withListRefsInvalidation( + cwd, + pushCurrentBranch(cwd, fallbackBranch, options).pipe( + Effect.tap((result) => resumeUpstreamRefreshIfPushed(cwd, result)), + ), + ); + /** + * Pulls the current branch with the interactive Git timeout, then lets + * background status fetch run again. + */ + const pullCurrentBranchAndResumeRefresh: GitVcsDriver.GitVcsDriver["Service"]["pullCurrentBranch"] = + (cwd) => + withListRefsInvalidation( + cwd, + pullCurrentBranch(cwd).pipe(Effect.tap(() => releaseTrackedUpstreamStatusRefresh(cwd))), + ); + /** + * Fetches a remote outside the status poll, then lets that poll run again. + */ + const fetchRemoteAndResumeRefresh: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = ( + input, + ) => + withListRefsInvalidation( + input.cwd, + fetchRemote(input).pipe( + Effect.tap(() => releaseStatusUpstreamRefresh(input.cwd, input.remoteName)), + ), + ); + /** + * Fetches one remote branch, then lets background status refresh poll that remote. + */ + const fetchRemoteBranchAndResumeRefresh: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = + (input) => + withListRefsInvalidation( + input.cwd, + fetchRemoteBranch(input).pipe( + Effect.tap(() => releaseStatusUpstreamRefresh(input.cwd, input.remoteName)), + ), + ); + /** + * Fetches a remote-tracking branch, then lets background status refresh poll that remote. + */ + const fetchRemoteTrackingBranchAndResumeRefresh: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = + (input) => + withListRefsInvalidation( + input.cwd, + fetchRemoteTrackingBranch(input).pipe( + Effect.tap(() => releaseStatusUpstreamRefresh(input.cwd, input.remoteName)), + ), + ); + return GitVcsDriver.GitVcsDriver.of({ execute, status, @@ -3674,9 +3926,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* prepareCommitContext, commit: (cwd, subject, body, options) => withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), - pushCurrentBranch: (cwd, fallbackBranch, options) => - withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), - pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), + pushCurrentBranch: pushCurrentBranchAndResumeRefresh, + pullCurrentBranch: pullCurrentBranchAndResumeRefresh, readRangeContext, getReviewDiffPreview, getReviewDiffFileContents, @@ -3693,13 +3944,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, resolveDefaultBranchName, - fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + fetchRemote: fetchRemoteAndResumeRefresh, remoteExists, remoteBranchExists, resolveRemoteTrackingCommit, - fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), - fetchRemoteTrackingBranch: (input) => - withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), + fetchRemoteBranch: fetchRemoteBranchAndResumeRefresh, + fetchRemoteTrackingBranch: fetchRemoteTrackingBranchAndResumeRefresh, setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..d623b13ebff7 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -309,6 +309,21 @@ describe("when: ref is clean, up to date, and has no open PR", () => { assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); + it("resolveQuickAction pulls when upstream refresh stopped on an up-to-date branch", () => { + const quick = resolveQuickAction( + status({ + aheadCount: 0, + behindCount: 0, + hasUpstream: true, + hasWorkingTreeChanges: false, + pr: null, + upstreamNeedsAttention: true, + }), + false, + ); + assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); + }); + it("buildMenuItems disables commit, push, and create PR", () => { const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); assert.deepEqual(items, [ diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..153f1201a56c 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -3,6 +3,7 @@ import type { GitStackedAction, VcsStatusResult, } from "@t3tools/contracts"; +import { offersUpstreamAttentionPull } from "@t3tools/client-runtime/state/vcs"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, @@ -164,6 +165,11 @@ export function buildMenuItems( ]; } +/** + * Picks the primary git button for the current branch. + * When background upstream refresh has stopped and the cached branch is not + * behind, the button is Pull so that refresh can be resumed. + */ export function resolveQuickAction( gitStatus: VcsStatusResult | null, isBusy: boolean, @@ -301,6 +307,14 @@ export function resolveQuickAction( }; } + if (offersUpstreamAttentionPull(gitStatus)) { + return { + label: "Pull", + disabled: false, + kind: "run_pull", + }; + } + return { label: "Commit", disabled: true, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c26f1e69b1d..67b005ea387b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,6 +4,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + offersUpstreamAttentionPull, + upstreamStatusAttentionMessage, +} from "@t3tools/client-runtime/state/vcs"; import type { GitActionProgressEvent, GitRunStackedActionResult, @@ -942,6 +946,11 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ); } +/** + * Git actions for the active thread. When background upstream refresh has + * stopped, the menu explains that and offers Pull even if the cached branch + * is not behind. + */ export default function GitActionsControl({ presentation = "toolbar", gitCwd, @@ -1081,6 +1090,7 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; + const upstreamAttention = upstreamStatusAttentionMessage(gitStatusForActions); const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); @@ -1138,6 +1148,8 @@ export default function GitActionsControl({ const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; + const showUpstreamAttentionPull = + offersUpstreamAttentionPull(gitStatusForActions) && quickAction.kind !== "run_pull"; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, @@ -1504,6 +1516,53 @@ export default function GitActionsControl({ }); }; + /** + * Pulls the current branch and reports the result. Used by the primary button + * and by the menu item shown when upstream refresh has stopped. + */ + const runPull = () => { + const toastId = toastManager.add({ + type: "loading", + title: "Pulling...", + timeout: 0, + data: threadToastData, + }); + void (async () => { + const result = await pullAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + const error = squashAtomCommandFailure(result); + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Pull failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + return; + } + + const pullResult = result.value; + toastManager.update(toastId, { + type: "success", + title: pullResult.status === "pulled" ? "Pulled" : "Already up to date", + description: + pullResult.status === "pulled" + ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` + : `${pullResult.refName} is already synchronized.`, + data: threadToastData, + }); + })(); + }; + + /** + * Runs the primary git button, including Pull when upstream refresh has stopped. + */ const runQuickAction = () => { if (quickAction.kind === "open_pr") { void openExistingPr(); @@ -1514,43 +1573,7 @@ export default function GitActionsControl({ return; } if (quickAction.kind === "run_pull") { - const toastId = toastManager.add({ - type: "loading", - title: "Pulling...", - timeout: 0, - data: threadToastData, - }); - void (async () => { - const result = await pullAction.run(); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - toastManager.close(toastId); - return; - } - const error = squashAtomCommandFailure(result); - toastManager.update( - toastId, - stackedThreadToast({ - type: "error", - title: "Pull failed", - description: error instanceof Error ? error.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - return; - } - - const pullResult = result.value; - toastManager.update(toastId, { - type: "success", - title: pullResult.status === "pulled" ? "Pulled" : "Already up to date", - description: - pullResult.status === "pulled" - ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` - : `${pullResult.refName} is already synchronized.`, - data: threadToastData, - }); - })(); + runPull(); return; } if (quickAction.kind === "show_hint") { @@ -1731,6 +1754,21 @@ export default function GitActionsControl({ gitStatusForActions.aheadCount === 0 && (

Behind upstream. Pull/rebase first.

)} + {upstreamAttention ? ( +

{upstreamAttention}

+ ) : null} + {showUpstreamAttentionPull ? ( + { + runPull(); + }} + > + + Pull + + ) : null} {gitStatusError &&

{gitStatusError}

} ); @@ -1834,7 +1872,18 @@ export default function GitActionsControl({ }} > } + render={ +