,
+ 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 ? (
+
+ ) : null}
{gitStatusError && {gitStatusError}
}
>
);
@@ -1834,7 +1872,18 @@ export default function GitActionsControl({
}}
>
}
+ render={
+
+ }
disabled={isGitActionRunning}
>
diff --git a/docs/user/source-control.md b/docs/user/source-control.md
index 408810571632..d2ef1b88e7c6 100644
--- a/docs/user/source-control.md
+++ b/docs/user/source-control.md
@@ -142,6 +142,11 @@ does not show its diff, so marks are made and read on web and desktop.
- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0.
- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS
remotes can require separate setup from the hosting provider's API access.
+- **macOS asks for the login keychain password after a Git upgrade:** background upstream
+ refresh stops for that remote after repeated timeouts, and Git actions report that upstream
+ status needs attention. Pull the branch — the action is available even when the branch does
+ not look behind — so Git can show the prompt long enough to choose Always Allow. A keychain
+ item that unlocks without a prompt keeps updating.
- **A review cannot load:** open it on the host website while resolving connectivity, permissions,
or rate limits.
diff --git a/packages/client-runtime/src/state/gitActions.test.ts b/packages/client-runtime/src/state/gitActions.test.ts
new file mode 100644
index 000000000000..43aaa6d2e05f
--- /dev/null
+++ b/packages/client-runtime/src/state/gitActions.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { offersUpstreamAttentionPull, upstreamStatusAttentionMessage } from "./gitActions.ts";
+
+describe("upstreamStatusAttentionMessage", () => {
+ it("stays quiet while background refresh is still running", () => {
+ expect(upstreamStatusAttentionMessage(null)).toBeNull();
+ expect(upstreamStatusAttentionMessage(undefined)).toBeNull();
+ expect(upstreamStatusAttentionMessage({})).toBeNull();
+ expect(upstreamStatusAttentionMessage({ upstreamNeedsAttention: false })).toBeNull();
+ });
+
+ it("tells the user to pull once upstream status needs attention", () => {
+ expect(upstreamStatusAttentionMessage({ upstreamNeedsAttention: true })).toBe(
+ "Upstream status needs attention. Pull to refresh it.",
+ );
+ });
+});
+
+describe("offersUpstreamAttentionPull", () => {
+ it("offers pull when refresh has stopped and the cached branch is not behind", () => {
+ expect(
+ offersUpstreamAttentionPull({
+ upstreamNeedsAttention: true,
+ hasUpstream: true,
+ behindCount: 0,
+ }),
+ ).toBe(true);
+ });
+
+ it("leaves pull to the existing behind action", () => {
+ expect(
+ offersUpstreamAttentionPull({
+ upstreamNeedsAttention: true,
+ hasUpstream: true,
+ behindCount: 2,
+ }),
+ ).toBe(false);
+ expect(
+ offersUpstreamAttentionPull({
+ upstreamNeedsAttention: false,
+ hasUpstream: true,
+ behindCount: 0,
+ }),
+ ).toBe(false);
+ expect(offersUpstreamAttentionPull(null)).toBe(false);
+ });
+});
diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts
index d3571e65d2e6..a9c37564b0d6 100644
--- a/packages/client-runtime/src/state/gitActions.ts
+++ b/packages/client-runtime/src/state/gitActions.ts
@@ -108,6 +108,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,
@@ -235,6 +240,14 @@ export function resolveQuickAction(
return { label: "View PR", disabled: false, kind: "open_pr" };
}
+ if (offersUpstreamAttentionPull(gitStatus)) {
+ return {
+ label: "Pull",
+ disabled: false,
+ kind: "run_pull",
+ };
+ }
+
return {
label: "Commit",
disabled: true,
@@ -356,3 +369,36 @@ export function resolveDefaultBranchActionDialogCopy(input: {
continueLabel: "Push & create PR",
};
}
+
+/**
+ * Notice shown when background upstream refresh has stopped, or null while
+ * that remote is still being polled.
+ *
+ * Surfaces that say this also offer Pull when the cached branch is not behind.
+ * Pull is not limited to the background fetch timeout, so a keychain prompt
+ * can be approved and refresh can resume.
+ */
+export function upstreamStatusAttentionMessage(
+ gitStatus: Pick | null | undefined,
+): string | null {
+ if (gitStatus?.upstreamNeedsAttention !== true) return null;
+ return "Upstream status needs attention. Pull to refresh it.";
+}
+
+/**
+ * True when Pull must be offered even though the cached branch is not behind.
+ * Behind branches already expose Pull. Without this, a stopped refresh on an
+ * up-to-date cache cannot be resumed from the git menu.
+ */
+export function offersUpstreamAttentionPull(
+ gitStatus:
+ | Pick
+ | null
+ | undefined,
+): boolean {
+ return (
+ gitStatus?.upstreamNeedsAttention === true &&
+ gitStatus.hasUpstream === true &&
+ gitStatus.behindCount === 0
+ );
+}
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index c5a5825e193e..e334399359df 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -235,6 +235,11 @@ const VcsStatusRemoteShape = {
aheadCount: NonNegativeInt,
behindCount: NonNegativeInt,
aheadOfDefaultCount: Schema.optional(NonNegativeInt),
+ /**
+ * Background upstream refresh stopped after repeated timeouts.
+ * Absent while that remote is still being polled. Pull to try again.
+ */
+ upstreamNeedsAttention: Schema.optional(Schema.Boolean),
pr: Schema.NullOr(VcsStatusChangeRequest),
};
diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts
index ce0defd0587b..446e82f4944d 100644
--- a/packages/shared/src/git.test.ts
+++ b/packages/shared/src/git.test.ts
@@ -273,4 +273,39 @@ describe("applyGitStatusStreamEvent", () => {
pr: null,
});
});
+
+ it("keeps a stopped upstream refresh across a local-only update", () => {
+ const current: VcsStatusResult = {
+ isRepo: true,
+ hasPrimaryRemote: true,
+ isDefaultRef: true,
+ refName: "main",
+ hasWorkingTreeChanges: false,
+ workingTree: { files: [], insertions: 0, deletions: 0 },
+ hasUpstream: true,
+ aheadCount: 0,
+ behindCount: 0,
+ upstreamNeedsAttention: true,
+ pr: null,
+ };
+
+ const next = applyGitStatusStreamEvent(current, {
+ _tag: "localUpdated",
+ local: {
+ isRepo: true,
+ hasPrimaryRemote: true,
+ isDefaultRef: true,
+ refName: "main",
+ hasWorkingTreeChanges: true,
+ workingTree: {
+ files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }],
+ insertions: 1,
+ deletions: 0,
+ },
+ },
+ });
+
+ expect(next.upstreamNeedsAttention).toBe(true);
+ expect(next.hasWorkingTreeChanges).toBe(true);
+ });
});
diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts
index 61d183141797..04db3de67b92 100644
--- a/packages/shared/src/git.ts
+++ b/packages/shared/src/git.ts
@@ -331,6 +331,10 @@ export function mergeGitStatusParts(
};
}
+/**
+ * Keeps the remote half of a status snapshot, including a stopped upstream
+ * refresh, so a later local-only update does not drop that flag.
+ */
function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
return {
hasUpstream: status.hasUpstream,
@@ -339,6 +343,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
...(status.aheadOfDefaultCount === undefined
? {}
: { aheadOfDefaultCount: status.aheadOfDefaultCount }),
+ ...(status.upstreamNeedsAttention ? { upstreamNeedsAttention: true as const } : {}),
pr: status.pr,
};
}