diff --git a/src/main/build-initial-state.ts b/src/main/build-initial-state.ts index edff98f7..d2d7b674 100644 --- a/src/main/build-initial-state.ts +++ b/src/main/build-initial-state.ts @@ -144,7 +144,8 @@ export function buildInitialAppState( dismissedAnnouncementIds: Array.isArray(config.dismissedAnnouncementIds) ? config.dismissedAnnouncementIds.filter((x): x is string => typeof x === 'string') : [], - announcementsMuted: config.announcementsMuted === true + announcementsMuted: config.announcementsMuted === true, + autoFetchEnabled: config.autoFetchEnabled !== false } } } diff --git a/src/main/fetch-poller.ts b/src/main/fetch-poller.ts new file mode 100644 index 00000000..c7f1ef36 --- /dev/null +++ b/src/main/fetch-poller.ts @@ -0,0 +1,63 @@ +import { fetchAllRemotes } from './worktree' +import { log, formatErr } from './debug' + +const FETCH_INTERVAL_MS = 3 * 60 * 1000 + +interface FetchPollerOptions { + getRepoRoots: () => string[] + /** Whether the background fetch is enabled. Re-read every tick so a + * settings toggle takes effect without restarting the poller. */ + isEnabled: () => boolean +} + +/** Periodically runs `git fetch --all` on every known repo so worktrees + * stay current with their remotes without a manual fetch. One fetch per + * repo root (a fetch from the root updates the shared object store that + * all of its worktrees read). Failures are swallowed per-repo — an + * offline remote or auth prompt on one repo doesn't stop the others, and + * the next tick simply tries again. */ +export class FetchPoller { + private opts: FetchPollerOptions + private timer: NodeJS.Timeout | null = null + private inFlight = false + + constructor(opts: FetchPollerOptions) { + this.opts = opts + } + + start(): void { + if (this.timer) return + this.timer = setInterval(() => { + void this.fetchAll() + }, FETCH_INTERVAL_MS) + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** Fetch every repo's remotes once. No-op while disabled or when a + * previous sweep is still running (a slow network shouldn't let ticks + * pile up). */ + async fetchAll(): Promise { + if (this.inFlight) return + if (!this.opts.isEnabled()) return + const roots = this.opts.getRepoRoots() + if (roots.length === 0) return + this.inFlight = true + try { + await Promise.all( + roots.map((root) => + fetchAllRemotes(root).catch((err) => { + log('fetch-poller', `fetch --all failed for ${root}`, formatErr(err)) + }) + ) + ) + } finally { + this.inFlight = false + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 32718428..6ed99881 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,7 @@ import type { BrowserManagerLike } from './browser-manager-types' import { PerfMonitor } from './perf-monitor' import { setGitHubApiRecorder, setGitHubApiLoggingEnabled } from './github-recorder' import { PRPoller } from './pr-poller' +import { FetchPoller } from './fetch-poller' import { WorktreesFSM } from './worktrees-fsm' import { WorktreeDeletionFSM } from './worktree-deletion-fsm' import { PanesFSM, stripTransientTabFields } from './panes-fsm' @@ -611,6 +612,11 @@ const prPoller = new PRPoller(store, { } }) +const fetchPoller = new FetchPoller({ + getRepoRoots: () => config.repoRoots || [], + isEnabled: () => config.autoFetchEnabled !== false +}) + const announcementsPoller = new AnnouncementsPoller(store) ptyManager.setStore(store) @@ -1803,6 +1809,23 @@ function registerIpcHandlers(): void { return true }) + transport.onRequest('config:setAutoFetchEnabled', (_ctx, enabled: boolean) => { + if (enabled) { + delete config.autoFetchEnabled + } else { + config.autoFetchEnabled = false + } + saveConfig(config) + store.dispatch({ + type: 'settings/autoFetchEnabledChanged', + payload: config.autoFetchEnabled !== false + }) + // Re-enabling shouldn't wait up to 3m for the next tick — fetch now. + // fetchAll() itself no-ops while disabled, so this is safe either way. + void fetchPoller.fetchAll() + return true + }) + transport.onRequest('config:setAutoUpdateEnabled', (_ctx, enabled: boolean) => { if (enabled) { delete config.autoUpdateEnabled @@ -3556,6 +3579,12 @@ async function runBoot(): Promise { announcementsPoller.start() + // Background `git fetch --all` across all repos every few minutes. The + // timer always runs; each tick no-ops when the setting is disabled. Kick + // an initial sweep at boot so worktrees start current. + fetchPoller.start() + void fetchPoller.fetchAll() + // Seed hooks.consent from disk and migrate legacy per-worktree hooks // to a single user-scope install. Runs once per app install; migrated // state sticks via config.hooksMigratedToGlobal. diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 58489380..4a18c22a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -164,6 +164,9 @@ export interface Config { // to the main worktree's copy, and the boot migration doesn't convert // existing regular files. Default is enabled (undefined/true). shareClaudeSettings?: boolean + // When false, the background `git fetch --all` poller is disabled for all + // repos. Default is enabled (undefined/true). + autoFetchEnabled?: boolean // User's choice for installing agent status hooks at user scope // (~/.claude/settings.json, ~/.codex/hooks.json). Persisted so a // declined user doesn't see the banner again on next launch. diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 3de06095..37af8e39 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -46,6 +46,16 @@ function getCreatedAt(path: string): number { } } +/** Fetch every remote for a repo — the equivalent of `git fetch --all`. + * Run from the repo root so all of its worktrees see the updated remote + * refs. Failures (offline, auth) throw; the background poller swallows + * them per-repo so one bad remote doesn't stop the others. */ +export async function fetchAllRemotes(repoRoot: string): Promise { + const t0 = performance.now() + await execFileAsync('git', ['fetch', '--all', '--quiet'], { cwd: repoRoot }) + perfLog('git-op', `fetchAllRemotes ${repoRoot} ${Math.round(performance.now() - t0)}ms`) +} + /** Get a sensible default directory for worktrees: -worktrees/ alongside the repo */ export function defaultWorktreeDir(repoRoot: string): string { const repoName = basename(repoRoot) diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c2ccab45..dda1d03f 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -272,6 +272,7 @@ export function buildBackend( setExpandedDiagnosticLoggingEnabled: (enabled: boolean) => req('config:setExpandedDiagnosticLoggingEnabled', enabled), setShareClaudeSettings: (enabled: boolean) => req('config:setShareClaudeSettings', enabled), + setAutoFetchEnabled: (enabled: boolean) => req('config:setAutoFetchEnabled', enabled), setHarnessSystemPromptEnabled: (enabled: boolean) => req('config:setHarnessSystemPromptEnabled', enabled), setHarnessSystemPrompt: (prompt: string) => req('config:setHarnessSystemPrompt', prompt), diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 50d60088..80acd14a 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -330,6 +330,7 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: harnessStarred, worktreeScripts, shareClaudeSettings, + autoFetchEnabled, autoUpdateEnabled, warnBeforeQuitting, harnessSystemPromptEnabled, @@ -2316,6 +2317,29 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: )} + {scopeRepoRoot === null && ( + <> +

Auto-fetch remotes

+

+ Runs{' '} + git fetch --all{' '} + on every repo every 3 minutes in the background so your + worktrees stay current with their remotes without a manual + fetch. Applies to all repos. +

+ + + )} +

Default merge strategy

{scopeRepoRoot === null && reposOverridingKey('mergeStrategy').length > 0 && ( diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 0ead0aa5..e78bfa2d 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -340,6 +340,7 @@ export interface ElectronAPI { setWarnBeforeQuitting(enabled: boolean): Promise setExpandedDiagnosticLoggingEnabled(enabled: boolean): Promise setShareClaudeSettings(enabled: boolean): Promise + setAutoFetchEnabled(enabled: boolean): Promise setHarnessSystemPromptEnabled(enabled: boolean): Promise setHarnessSystemPrompt(prompt: string): Promise setHarnessSystemPromptMain(prompt: string): Promise diff --git a/src/shared/state/settings.test.ts b/src/shared/state/settings.test.ts index 70f42b7d..73daf52b 100644 --- a/src/shared/state/settings.test.ts +++ b/src/shared/state/settings.test.ts @@ -575,6 +575,20 @@ describe('settingsReducer', () => { expect(off.announcementsMuted).toBe(false) }) + it('autoFetchEnabledChanged toggles the auto-fetch flag', () => { + expect(initialSettings.autoFetchEnabled).toBe(true) + const off = apply(initialSettings, { + type: 'settings/autoFetchEnabledChanged', + payload: false + }) + expect(off.autoFetchEnabled).toBe(false) + const on = apply(off, { + type: 'settings/autoFetchEnabledChanged', + payload: true + }) + expect(on.autoFetchEnabled).toBe(true) + }) + it('returns a new object reference (no mutation)', () => { const next = apply(initialSettings, { type: 'settings/themeDarkChanged', payload: 'dracula' }) expect(next).not.toBe(initialSettings) diff --git a/src/shared/state/settings.ts b/src/shared/state/settings.ts index 63616fdd..2bfbc5b5 100644 --- a/src/shared/state/settings.ts +++ b/src/shared/state/settings.ts @@ -216,6 +216,10 @@ export interface SettingsState { * the feed contents. Set by the "Hide all announcements" action and * cleared only by the user. */ announcementsMuted: boolean + /** When true (default), a background task runs `git fetch --all` on + * every known repo every few minutes so worktrees stay current with + * their remotes without a manual fetch. Applies to all repos. */ + autoFetchEnabled: boolean } export type SettingsEvent = @@ -274,6 +278,7 @@ export type SettingsEvent = | { type: 'settings/prReviewPromptChanged'; payload: string } | { type: 'settings/announcementDismissed'; payload: string } | { type: 'settings/announcementsMutedChanged'; payload: boolean } + | { type: 'settings/autoFetchEnabledChanged'; payload: boolean } // Client-side placeholder. Real values are seeded in the main-process Store // constructor from the on-disk config and secrets. @@ -329,7 +334,8 @@ export const initialSettings: SettingsState = { expandedDiagnosticLoggingEnabled: false, prReviewPrompt: DEFAULT_PR_REVIEW_PROMPT, dismissedAnnouncementIds: [], - announcementsMuted: false + announcementsMuted: false, + autoFetchEnabled: true } export function settingsReducer(state: SettingsState, event: SettingsEvent): SettingsState { @@ -443,6 +449,8 @@ export function settingsReducer(state: SettingsState, event: SettingsEvent): Set } case 'settings/announcementsMutedChanged': return { ...state, announcementsMuted: event.payload } + case 'settings/autoFetchEnabledChanged': + return { ...state, autoFetchEnabled: event.payload } default: { const _exhaustive: never = event void _exhaustive