diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts
index 5960685ef4..47bd51424d 100644
--- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts
+++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts
@@ -62,9 +62,11 @@ describe('getWorkspaceGitBasicInfoOptions', () => {
});
});
- it('keeps remote workspace rows on the existing default git refresh behavior', () => {
+ it('defers inactive remote workspace rows until activation too', () => {
expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), false))
- .toBeUndefined();
+ .toMatchObject({ isActive: false, refreshOnMount: false, refreshOnActive: true });
+ expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), true))
+ .toMatchObject({ isActive: true, refreshOnMount: true, refreshOnActive: true });
});
it('suppresses only mount refresh during a history session transition', () => {
diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts
index acf0f5d6fe..a0713f5984 100644
--- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts
+++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts
@@ -1,4 +1,4 @@
-import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types';
+import type { WorkspaceInfo } from '@/shared/types';
import type { GitBasicInfoOptions } from '@/tools/git/hooks/useGitState';
export const WORKSPACE_GIT_BASIC_INFO_SOURCE = 'workspace_item_git_basic_info';
@@ -10,13 +10,9 @@ export const WORKSPACE_GIT_PENDING_CANCEL_SOURCES = [
export const WORKSPACE_GIT_PENDING_CANCEL_REASONS = ['mount', 'visibility'] as const;
export function getWorkspaceGitBasicInfoOptions(
- workspace: WorkspaceInfo,
+ _workspace: WorkspaceInfo,
isActive: boolean
): GitBasicInfoOptions | undefined {
- if (isRemoteWorkspace(workspace)) {
- return undefined;
- }
-
return {
isActive,
refreshOnMount: isActive,
diff --git a/src/web-ui/src/tools/git/AGENTS.md b/src/web-ui/src/tools/git/AGENTS.md
new file mode 100644
index 0000000000..b4b4ba1f15
--- /dev/null
+++ b/src/web-ui/src/tools/git/AGENTS.md
@@ -0,0 +1,16 @@
+# Git state and refresh verification
+
+For changes to Git state subscriptions, polling, refresh scheduling, or workspace
+row activation, run the focused contracts from the repository root:
+
+```bash
+pnpm --dir src/web-ui run test:run src/tools/git/state/GitStateManager.test.ts src/tools/git/hooks/useGitState.test.tsx src/tools/git/services/GitService.test.ts src/tools/git/services/WorkspaceGitInitializer.test.ts src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts
+```
+
+Subscriptions observe state; only active consumers register polling demand.
+Keep remote polling conservative, serialize background polls per SSH connection,
+and preserve manual/operation refreshes during retry backoff. Unchanged refreshes
+must not emit state-change events. Retain repository trust error/recovery behavior.
+
+These tests mock host Git calls. Report live SSH and peer-device verification
+separately; fake timers and mocked transports are not remote integration evidence.
diff --git a/src/web-ui/src/tools/git/hooks/useGitState.test.tsx b/src/web-ui/src/tools/git/hooks/useGitState.test.tsx
index 8a049e86d7..1c3f5cfbc6 100644
--- a/src/web-ui/src/tools/git/hooks/useGitState.test.tsx
+++ b/src/web-ui/src/tools/git/hooks/useGitState.test.tsx
@@ -10,6 +10,7 @@ import { useGitState } from './useGitState';
const gitStateManagerMock = vi.hoisted(() => ({
getState: vi.fn(() => null),
subscribe: vi.fn(() => vi.fn()),
+ registerPolling: vi.fn(() => vi.fn()),
registerWindowFocusRefresh: vi.fn(() => vi.fn()),
refresh: vi.fn(() => Promise.resolve()),
cancelPendingRefresh: vi.fn(() => false),
@@ -67,6 +68,8 @@ describe('useGitState visibility refresh', () => {
});
expect(gitStateManagerMock.refresh).not.toHaveBeenCalled();
+ expect(gitStateManagerMock.subscribe).toHaveBeenCalledTimes(1);
+ expect(gitStateManagerMock.registerPolling).not.toHaveBeenCalled();
expect(gitStateManagerMock.registerWindowFocusRefresh).not.toHaveBeenCalled();
await act(async () => {
@@ -83,6 +86,13 @@ describe('useGitState visibility refresh', () => {
},
);
expect(gitStateManagerMock.registerWindowFocusRefresh).toHaveBeenCalledTimes(1);
+ expect(gitStateManagerMock.registerPolling).toHaveBeenCalledTimes(1);
+ const releasePolling = gitStateManagerMock.registerPolling.mock.results[0].value;
+ await act(async () => {
+ root.render();
+ });
+ expect(releasePolling).toHaveBeenCalledTimes(1);
+ expect(gitStateManagerMock.subscribe).toHaveBeenCalledTimes(1);
});
it('cancels and replays active automatic refreshes when mount refresh is temporarily suppressed', async () => {
diff --git a/src/web-ui/src/tools/git/hooks/useGitState.ts b/src/web-ui/src/tools/git/hooks/useGitState.ts
index 0cb88cb24b..f8aefdc3f3 100644
--- a/src/web-ui/src/tools/git/hooks/useGitState.ts
+++ b/src/web-ui/src/tools/git/hooks/useGitState.ts
@@ -122,6 +122,11 @@ export function useGitState(options: UseGitStateOptions): UseGitStateReturn {
const isFirstMountRef = useRef(true);
+ useEffect(() => {
+ if (!normalizedPath.workspaceId || !isActive) return;
+ return gitStateManager.registerPolling(normalizedPath);
+ }, [isActive, normalizedPath]);
+
useEffect(() => {
if (!normalizedPath.workspaceId || !isActive || !participateInWindowFocusRefresh) {
return;
diff --git a/src/web-ui/src/tools/git/state/GitStateManager.test.ts b/src/web-ui/src/tools/git/state/GitStateManager.test.ts
index 8e8ac063cb..a4e5edc455 100644
--- a/src/web-ui/src/tools/git/state/GitStateManager.test.ts
+++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts
@@ -2,6 +2,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GitStateManager } from './GitStateManager';
import type { GitStateLayer } from './types';
+import { globalEventBus } from '@/infrastructure/event-bus';
+
+const workspaceCatalog = vi.hoisted(() => new Map());
+const peerMode = vi.hoisted(() => ({ active: false }));
+vi.mock('@/infrastructure/services/business/workspaceManager', () => ({
+ workspaceManager: { getState: () => ({ openedWorkspaces: workspaceCatalog }) },
+}));
+vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({
+ isPeerDeviceModeActive: () => peerMode.active,
+}));
const gitApiMocks = vi.hoisted(() => ({
isGitRepository: vi.fn(),
@@ -59,6 +69,10 @@ describe('GitStateManager refresh performance guards', () => {
beforeEach(() => {
vi.useFakeTimers();
+ workspaceCatalog.clear();
+ workspaceCatalog.set('workspace-1', { workspaceKind: 'normal' });
+ workspaceCatalog.set('workspace-2', { workspaceKind: 'normal' });
+ peerMode.active = false;
GitStateManager.resetInstance();
manager = GitStateManager.getInstance();
manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 });
@@ -103,10 +117,10 @@ describe('GitStateManager refresh performance guards', () => {
const addListener = vi.spyOn(document, 'addEventListener');
const removeListener = vi.spyOn(document, 'removeEventListener');
try {
- const unsubscribe = manager.subscribe(repositoryPath, () => {});
+ const unsubscribe = manager.registerPolling(repositoryPath);
hidden.mockReturnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
- const unsubscribeOther = manager.subscribe({ workspaceId: 'workspace-2' }, () => {});
+ const unsubscribeOther = manager.registerPolling({ workspaceId: 'workspace-2' });
await vi.advanceTimersByTimeAsync(4100);
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
const listeners = addListener.mock.calls.filter(([type]) => type === 'visibilitychange');
@@ -131,7 +145,7 @@ describe('GitStateManager refresh performance guards', () => {
it('does not start polling for an initially hidden document', async () => {
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
try {
- manager.subscribe(repositoryPath, () => {});
+ manager.registerPolling(repositoryPath);
await vi.advanceTimersByTimeAsync(4100);
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
hidden.mockReturnValue(false);
@@ -164,6 +178,171 @@ describe('GitStateManager refresh performance guards', () => {
});
});
+ it('keeps passive subscriptions free of IO and reference-counts active demand', async () => {
+ manager.subscribe(repositoryPath, () => {});
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
+ const releaseA = manager.registerPolling(repositoryPath);
+ const releaseB = manager.registerPolling(repositoryPath);
+ releaseA();
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1);
+ releaseB();
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1);
+ });
+
+ it('polls active SSH workspaces slowly and never broadcasts unchanged results', async () => {
+ workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' });
+ workspaceCatalog.set('workspace-2', { workspaceKind: 'remote', connectionId: 'ssh-b' });
+ for (const workspaceId of ['workspace-1', 'workspace-2', 'workspace-3']) {
+ manager.subscribe({ workspaceId }, () => {});
+ }
+ manager.registerPolling(repositoryPath);
+ await vi.advanceTimersByTimeAsync(29_999);
+ expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(101);
+ expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1);
+ vi.mocked(globalEventBus.emit).mockClear();
+ gitEventServiceMock.emit.mockClear();
+ await vi.advanceTimersByTimeAsync(36_000);
+ expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(2);
+ expect(globalEventBus.emit).not.toHaveBeenCalled();
+ expect(gitEventServiceMock.emit).not.toHaveBeenCalled();
+ expect(gitApiMocks.getStatus).not.toHaveBeenCalled();
+ });
+
+ it('serializes background reads on one SSH connection without starving another repository', async () => {
+ for (const workspaceId of ['workspace-1', 'workspace-2']) {
+ workspaceCatalog.set(workspaceId, { workspaceKind: 'remote', connectionId: 'ssh-a' });
+ manager.registerPolling({ workspaceId });
+ }
+ const read = deferred();
+ gitApiMocks.getRepositoryBasic.mockReturnValueOnce(read.promise);
+ await vi.advanceTimersByTimeAsync(40_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
+ read.resolve({ current_branch: 'main' });
+ await vi.advanceTimersByTimeAsync(2100);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
+ expect(gitApiMocks.isGitRepository.mock.calls.map(([scope]) => scope.workspaceId))
+ .toEqual(['workspace-1', 'workspace-2']);
+ });
+
+ it('backs off failures, allows immediate manual recovery and clears the visible error', async () => {
+ workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' });
+ const seen = vi.fn();
+ manager.subscribe(repositoryPath, seen);
+ manager.registerPolling(repositoryPath);
+ gitApiMocks.isGitRepository.mockRejectedValueOnce(new Error('SSH disconnected'));
+ await vi.advanceTimersByTimeAsync(30_100);
+ expect(seen.mock.lastCall?.[0].error).toBe('SSH disconnected');
+ await vi.advanceTimersByTimeAsync(59_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
+ const retry = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
+ await vi.advanceTimersByTimeAsync(100);
+ await retry;
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
+ expect(seen.mock.lastCall?.[0].error).toBeNull();
+ await vi.advanceTimersByTimeAsync(33_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(3);
+ });
+
+ it('preserves peer-device polling suppression and uses conservative cadence without metadata', async () => {
+ workspaceCatalog.clear();
+ manager.registerPolling(repositoryPath);
+ await vi.advanceTimersByTimeAsync(6000);
+ expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
+ peerMode.active = true;
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
+ peerMode.active = false;
+ });
+
+ it('increases offline retry delays and caps them at five minutes', async () => {
+ workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' });
+ gitApiMocks.isGitRepository.mockRejectedValue(new Error('SSH disconnected'));
+ manager.registerPolling(repositoryPath);
+ await vi.advanceTimersByTimeAsync(30_100);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
+ await vi.advanceTimersByTimeAsync(120_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(3);
+ await vi.advanceTimersByTimeAsync(242_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(4);
+ await vi.advanceTimersByTimeAsync(302_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(5);
+ await vi.advanceTimersByTimeAsync(302_000);
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(6);
+ });
+
+ it('cancels queued polling on deactivation but preserves a merged manual refresh', async () => {
+ const release = manager.registerPolling(repositoryPath);
+ await vi.advanceTimersByTimeAsync(2000);
+ release();
+ await vi.advanceTimersByTimeAsync(100);
+ expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
+ const releaseAgain = manager.registerPolling(repositoryPath);
+ await vi.advanceTimersByTimeAsync(2000);
+ const manual = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
+ releaseAgain();
+ await vi.advanceTimersByTimeAsync(100);
+ await manual;
+ expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a queued poll if the document becomes hidden during debounce', async () => {
+ const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(false);
+ try {
+ manager.registerPolling(repositoryPath);
+ await vi.advanceTimersByTimeAsync(2000);
+ hidden.mockReturnValue(true);
+ document.dispatchEvent(new Event('visibilitychange'));
+ await vi.advanceTimersByTimeAsync(100);
+ expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
+ } finally {
+ hidden.mockRestore();
+ }
+ });
+
+ it('notifies error recovery even when the branch and files stay unchanged', async () => {
+ const first = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
+ await vi.advanceTimersByTimeAsync(100);
+ await first;
+ const seen = vi.fn();
+ manager.subscribe(repositoryPath, seen, { immediate: false, layers: ['basic'] });
+ gitApiMocks.isGitRepository.mockRejectedValueOnce(new Error('Offline'));
+ const failed = manager.refresh(repositoryPath, { layers: ['basic'], force: true }).catch(() => {});
+ await vi.advanceTimersByTimeAsync(100);
+ await failed;
+ expect(seen.mock.lastCall?.[0].error).toBe('Offline');
+ const recovered = manager.refresh(repositoryPath, { layers: ['basic'], force: true, silent: true });
+ await vi.advanceTimersByTimeAsync(100);
+ await recovered;
+ expect(seen).toHaveBeenCalledTimes(2);
+ expect(seen.mock.lastCall?.[0].error).toBeNull();
+ });
+
+ it('refreshes stale basic data on activation even with the default infinite TTL', async () => {
+ manager.setCacheConfig({ basic: Infinity });
+ const first = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
+ await vi.advanceTimersByTimeAsync(100);
+ await first;
+ await vi.advanceTimersByTimeAsync(30_000);
+ manager.registerPolling(repositoryPath);
+ gitApiMocks.getRepositoryBasic.mockResolvedValueOnce({ current_branch: 'external-change' });
+ const activate = manager.refresh(repositoryPath, { layers: ['basic'], reason: 'visibility' });
+ await vi.advanceTimersByTimeAsync(100);
+ await activate;
+ expect(manager.getState(repositoryPath)?.currentBranch).toBe('external-change');
+ expect(globalEventBus.emit).toHaveBeenLastCalledWith('git:state:changed',
+ expect.objectContaining({ changedLayers: ['basic'] }));
+ });
+
it('merges duplicate mount refreshes for the same repository and layer', async () => {
const first = manager.refresh(repositoryPath, {
layers: ['basic', 'status'],
diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts
index d3199d9da0..58600c06a3 100644
--- a/src/web-ui/src/tools/git/state/GitStateManager.ts
+++ b/src/web-ui/src/tools/git/state/GitStateManager.ts
@@ -21,6 +21,8 @@ import { gitRepositoryUntrustedPath } from '@/infrastructure/api/errors/TauriCom
import { gitEventService } from '../services/GitEventService';
import { globalEventBus } from '@/infrastructure/event-bus';
import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag';
+import { workspaceManager } from '@/infrastructure/services/business/workspaceManager';
+import { isRemoteWorkspace } from '@/shared/types/global-state';
import {
GitState,
GitStateLayer,
@@ -52,22 +54,25 @@ interface SubscriberEntry {
* the app — in an integrated terminal, an external Git client, a script, or
* triggered by the agent itself. None of those emit our in-process
* `branch:changed` / `operation:completed` events, so the UI can stay stale
- * indefinitely. We therefore run a low-frequency poll against any repository
- * that currently has at least one active subscriber, so the branch chip,
- * welcome panel and Git scene all pick up external changes promptly.
- *
- * The poll hits the lightweight `basic` endpoint (`git branch --show-current`
- * plus repo name), so it is cheap even at 2 s cadence. Heavy `status` and
- * `detailed` layers keep their longer TTLs and are only refreshed on demand
- * or by user-driven events.
+ * indefinitely. Only active consumers request polling; passive subscriptions
+ * still receive changes without causing IO. Remote reads open SSH exec channels,
+ * so they use a slower cadence and never overlap on the same connection.
*/
const POLL_INTERVAL_MS = 2000;
+const REMOTE_POLL_INTERVAL_MS = 30_000;
+const MAX_POLL_BACKOFF_MS = 300_000;
+
+interface PollSchedule {
+ failures: number;
+ nextAttemptAt: number;
+}
interface PendingRefresh {
layers: Set;
force: boolean;
reason: RefreshReason;
source?: string;
+ pollOnly: boolean;
resolve: () => void;
reject: (error: Error) => void;
}
@@ -81,6 +86,9 @@ export class GitStateManager {
private states = new Map();
private subscribers = new Map>();
+ private pollingCounts = new Map();
+ private pollSchedules = new Map();
+ private pollingConnections = new Set();
private windowFocusRefreshCounts = new Map();
private refreshDebounceTimers = new Map>();
private refreshLocks = new Map>();
@@ -128,8 +136,6 @@ export class GitStateManager {
this.subscribers.set(normalizedPath, new Set());
}
- const firstSubscriberForRepo = this.subscribers.get(normalizedPath)!.size === 0;
-
const entry: SubscriberEntry = { callback, options };
this.subscribers.get(normalizedPath)!.add(entry);
@@ -140,23 +146,76 @@ export class GitStateManager {
}
}
- if (firstSubscriberForRepo) {
- this.ensurePollingStarted();
- }
-
return () => {
const set = this.subscribers.get(normalizedPath);
if (!set) return;
set.delete(entry);
if (set.size === 0) {
this.subscribers.delete(normalizedPath);
- if (this.subscribers.size === 0) {
- this.stopPolling();
+ }
+ };
+ }
+
+ /** Active consumers opt into IO separately from observing cached state. */
+ registerPolling(scope: GitWorkspaceScope): () => void {
+ const key = this.normalizePath(scope);
+ this.pollingCounts.set(key, (this.pollingCounts.get(key) ?? 0) + 1);
+ if (!this.pollSchedules.has(key)) {
+ this.pollSchedules.set(key, {
+ failures: 0,
+ nextAttemptAt: Date.now() + this.pollPolicy(scope).intervalMs,
+ });
+ }
+ // An activation refresh must not be swallowed by the basic layer's Infinity
+ // TTL. Keep a recent read reusable when several consumers mount together.
+ const lastRead = this.states.get(key)?.lastRefreshTime.basic ?? 0;
+ if (Date.now() - lastRead >= this.pollPolicy(scope).intervalMs) {
+ this.invalidateCache(scope, ['basic']);
+ }
+ this.ensurePollingStarted();
+ return () => {
+ const count = this.pollingCounts.get(key) ?? 0;
+ if (count > 1) {
+ this.pollingCounts.set(key, count - 1);
+ } else {
+ this.pollingCounts.delete(key);
+ // Use the captured surface key; cleanup may run after a device switch.
+ // A manual/operation refresh merged into this batch must still execute.
+ const pending = this.pendingRefreshes.get(key);
+ if (pending?.pollOnly) {
+ clearTimeout(this.refreshDebounceTimers.get(key));
+ this.refreshDebounceTimers.delete(key);
+ this.pendingRefreshes.delete(key);
+ pending.resolve();
}
+ if (this.pollingCounts.size === 0) this.stopPolling();
}
};
}
+ private pollPolicy(scope: GitWorkspaceScope): { intervalMs: number; connectionId?: string } {
+ const workspace = workspaceManager.getState().openedWorkspaces.get(scope.workspaceId);
+ if (!workspace || isRemoteWorkspace(workspace)) {
+ // Missing catalog metadata must never select the aggressive local cadence.
+ return {
+ intervalMs: REMOTE_POLL_INTERVAL_MS,
+ connectionId: workspace?.connectionId ?? scope.workspaceId,
+ };
+ }
+ return { intervalMs: POLL_INTERVAL_MS };
+ }
+
+ private scheduleNextPoll(key: string, failed: boolean): void {
+ const failures = failed ? Math.min((this.pollSchedules.get(key)?.failures ?? 0) + 1, 8) : 0;
+ this.pollSchedules.set(key, {
+ failures,
+ nextAttemptAt: Date.now() + Math.min(
+ this.pollPolicy(this.scopeForKey(key)).intervalMs * 2 ** failures,
+ MAX_POLL_BACKOFF_MS,
+ ),
+ });
+ }
+
/**
* Get current state synchronously (cached).
*/
@@ -294,6 +353,9 @@ export class GitStateManager {
this.stopPolling();
this.subscribers.clear();
+ this.pollingCounts.clear();
+ this.pollSchedules.clear();
+ this.pollingConnections.clear();
this.states.clear();
@@ -338,6 +400,7 @@ export class GitStateManager {
let pending = this.pendingRefreshes.get(repositoryPath);
if (pending) {
+ pending.pollOnly = pending.pollOnly && reason === 'interval';
for (const layer of layers) {
pending.layers.add(layer);
@@ -355,6 +418,7 @@ export class GitStateManager {
force,
reason,
source,
+ pollOnly: reason === 'interval',
resolve: () => {},
reject: () => {},
};
@@ -398,6 +462,14 @@ export class GitStateManager {
this.pendingRefreshes.delete(repositoryPath);
this.refreshDebounceTimers.delete(repositoryPath);
+ if (pending.pollOnly && (
+ !this.pollingCounts.has(repositoryPath) || document.hidden || isPeerDeviceModeActive() ||
+ gitWorkspaceKey(this.scopeForKey(repositoryPath)) !== repositoryPath
+ )) {
+ pending.resolve();
+ return;
+ }
+
const { layers, force, reason, source, resolve, reject } = pending;
try {
@@ -507,22 +579,24 @@ export class GitStateManager {
lastRefreshTime: newLastRefreshTime,
isRefreshing: false,
refreshingLayers: new Set(),
- ...(probedTrust ? { repositoryTrustRequired: false } : {}),
+ ...(probedTrust ? { repositoryTrustRequired: false, error: null } : {}),
});
+ if (probedTrust) this.scheduleNextPoll(repositoryPath, false);
const finalState = this.getState(this.scopeForKey(repositoryPath))!;
const comparison = compareStates(prevState, finalState);
if (comparison.hasChanges) {
this.notifySubscribers(repositoryPath, finalState, prevState, comparison.changedLayers);
+ this.emitStateChanged(repositoryPath, finalState, comparison.changedLayers, reason);
+ } else if (prevState.error !== finalState.error) {
+ this.notifySubscribers(repositoryPath, finalState, prevState, layersToRefresh);
}
-
- this.emitStateChanged(repositoryPath, finalState, layersToRefresh, reason);
-
} catch (error) {
probeOutcome = 'error';
probeError = error instanceof Error ? error.message : String(error);
+ this.scheduleNextPoll(repositoryPath, true);
// The ownership wall reaches here as a stable code, not as prose. Say
// what happened instead of showing the raw code to the user.
//
@@ -558,7 +632,7 @@ export class GitStateManager {
// that would drop a blocked repository back to "not a repository" — the
// status read throws before `isRepository` is ever set — and send the
// user to initialize one over a repository that is already there.
- this.updateState(repositoryPath, {
+ const failedState = this.updateState(repositoryPath, {
isRefreshing: false,
refreshingLayers: new Set(),
error: errorMessage,
@@ -568,6 +642,10 @@ export class GitStateManager {
? { repositoryTrustRequired: false }
: {}),
});
+ if (prevState.error !== failedState.error ||
+ prevState.repositoryTrustRequired !== failedState.repositoryTrustRequired) {
+ this.notifySubscribers(repositoryPath, failedState, prevState, layersToRefresh);
+ }
throw error;
} finally {
@@ -646,65 +724,60 @@ export class GitStateManager {
repositoryPath: string,
layersToRefresh: GitStateLayer[]
): Promise {
- try {
- const isRepo = await gitAPI.isGitRepository(this.scopeForKey(repositoryPath));
-
- // The probe answers `true` for a repository Git refuses on ownership
- // grounds — local and remote alike — so `false` here really is "no
- // repository", and clearing the trust flag states a fact rather than
- // hiding one. The ownership case falls through to the status read below,
- // whose stable error sets the flag in the caller's catch.
- if (!isRepo) {
- this.updateState(repositoryPath, {
- isRepository: false,
- repositoryTrustRequired: false,
- currentBranch: null,
- ahead: 0,
- behind: 0,
- hasChanges: false,
- staged: [],
- unstaged: [],
- untracked: [],
- conflicts: [],
- });
- return;
- }
-
- const shouldRefreshStatus = layersToRefresh.includes('status');
- if (!shouldRefreshStatus) {
- const repository = await gitAPI.getRepositoryBasic(this.scopeForKey(repositoryPath));
- const currentState = this.getOrCreateState(this.scopeForKey(repositoryPath));
- this.updateState(repositoryPath, {
- isRepository: true,
- currentBranch: repository.current_branch || repository.branch || null,
- hasChanges: currentState.hasChanges,
- });
- return;
- }
-
- const status = await gitAPI.getStatus(this.scopeForKey(repositoryPath), 'git_state_manager');
-
- const hasChanges =
- (status.staged?.length || 0) > 0 ||
- (status.unstaged?.length || 0) > 0 ||
- (status.untracked?.length || 0) > 0;
+ const isRepo = await gitAPI.isGitRepository(this.scopeForKey(repositoryPath));
+
+ // The probe answers `true` for a repository Git refuses on ownership
+ // grounds — local and remote alike — so `false` here really is "no
+ // repository", and clearing the trust flag states a fact rather than
+ // hiding one. The ownership case falls through to the status read below,
+ // whose stable error sets the flag in the caller's catch.
+ if (!isRepo) {
+ this.updateState(repositoryPath, {
+ isRepository: false,
+ repositoryTrustRequired: false,
+ currentBranch: null,
+ ahead: 0,
+ behind: 0,
+ hasChanges: false,
+ staged: [],
+ unstaged: [],
+ untracked: [],
+ conflicts: [],
+ });
+ return;
+ }
+ const shouldRefreshStatus = layersToRefresh.includes('status');
+ if (!shouldRefreshStatus) {
+ const repository = await gitAPI.getRepositoryBasic(this.scopeForKey(repositoryPath));
+ const currentState = this.getOrCreateState(this.scopeForKey(repositoryPath));
this.updateState(repositoryPath, {
isRepository: true,
- currentBranch: status.current_branch || null,
- ahead: status.ahead || 0,
- behind: status.behind || 0,
- hasChanges,
- staged: status.staged || [],
- unstaged: status.unstaged || [],
- untracked: status.untracked || [],
- conflicts: status.conflicts || [],
+ currentBranch: repository.current_branch || repository.branch || null,
+ hasChanges: currentState.hasChanges,
});
-
- } catch (error) {
- log.error('Failed to refresh basic and status', { repositoryPath, error });
- throw error;
+ return;
}
+
+ const status = await gitAPI.getStatus(this.scopeForKey(repositoryPath), 'git_state_manager');
+
+ const hasChanges =
+ (status.staged?.length || 0) > 0 ||
+ (status.unstaged?.length || 0) > 0 ||
+ (status.untracked?.length || 0) > 0;
+
+ this.updateState(repositoryPath, {
+ isRepository: true,
+ currentBranch: status.current_branch || null,
+ ahead: status.ahead || 0,
+ behind: status.behind || 0,
+ hasChanges,
+ staged: status.staged || [],
+ unstaged: status.unstaged || [],
+ untracked: status.untracked || [],
+ conflicts: status.conflicts || [],
+ });
+
}
/**
@@ -1001,7 +1074,7 @@ export class GitStateManager {
* Start the interval-based poll if not already running. The poll is global
* (one timer for all repositories) and pauses while the document is hidden
* to avoid unnecessary work in background tabs. It only refreshes repos
- * that currently have subscribers and always uses a silent basic-layer
+ * that currently have polling consumers and always uses a silent basic-layer
* refresh so status/detailed caches are not thrashed.
*/
private ensurePollingStarted(): void {
@@ -1011,7 +1084,7 @@ export class GitStateManager {
const handleVisibility = (): void => {
if (document.hidden) {
this.stopPollingTimerOnly();
- } else if (this.subscribers.size > 0) {
+ } else if (this.pollingCounts.size > 0) {
// Kick off an immediate refresh so we don't lag by up to one
// interval after becoming visible, then resume the timer.
this.pollTick();
@@ -1033,7 +1106,7 @@ export class GitStateManager {
}
private startPollingTimer(): void {
- if (this.pollTimer !== null || this.subscribers.size === 0) return;
+ if (this.pollTimer !== null || this.pollingCounts.size === 0) return;
this.pollTimer = setInterval(() => {
this.pollTick();
}, POLL_INTERVAL_MS);
@@ -1060,8 +1133,14 @@ export class GitStateManager {
// on the transport's state sync instead of issuing local git calls.
return;
}
- for (const repoKey of this.subscribers.keys()) {
+ for (const repoKey of this.pollingCounts.keys()) {
const scope = this.scopeForKey(repoKey);
+ if (gitWorkspaceKey(scope) !== repoKey) continue;
+ if ((this.pollSchedules.get(repoKey)?.nextAttemptAt ?? 0) > Date.now() ||
+ this.refreshLocks.has(repoKey) || this.pendingRefreshes.has(repoKey)) continue;
+ const { connectionId } = this.pollPolicy(scope);
+ if (connectionId && this.pollingConnections.has(connectionId)) continue;
+ if (connectionId) this.pollingConnections.add(connectionId);
// Invalidate the basic-layer timestamp so the non-forced refresh below
// bypasses the Infinity TTL and actually hits Git. We only invalidate
// `basic` — status/detailed keep their longer TTLs and are refreshed
@@ -1071,8 +1150,10 @@ export class GitStateManager {
layers: ['basic'],
reason: 'interval',
silent: true,
- }).catch((error) => {
- log.debug('Poll-tick refresh failed', { repositoryPath: repoKey, error });
+ }).catch(() => {
+ // doRefresh reports the failure and schedules exponential retry backoff.
+ }).finally(() => {
+ if (connectionId) this.pollingConnections.delete(connectionId);
});
}
}
diff --git a/src/web-ui/src/tools/git/state/types.ts b/src/web-ui/src/tools/git/state/types.ts
index 3390dd813d..6a06bed3ac 100644
--- a/src/web-ui/src/tools/git/state/types.ts
+++ b/src/web-ui/src/tools/git/state/types.ts
@@ -231,6 +231,7 @@ export function compareStates(
const statusFields: (keyof GitState)[] = ['staged', 'unstaged', 'untracked', 'conflicts'];
for (const field of statusFields) {
+ if (prevState[field] === newState[field]) continue;
const prevVal = JSON.stringify(prevState[field]);
const newVal = JSON.stringify(newState[field]);
if (prevVal !== newVal) {
@@ -244,6 +245,7 @@ export function compareStates(
const detailedFields: (keyof GitState)[] = ['branches', 'commits'];
for (const field of detailedFields) {
+ if (prevState[field] === newState[field]) continue;
const prevVal = JSON.stringify(prevState[field]);
const newVal = JSON.stringify(newState[field]);
if (prevVal !== newVal) {