From 2911b224d96c5f62911859b278e1f30bc78fcefa Mon Sep 17 00:00:00 2001 From: Sterling Greene Date: Thu, 4 Jun 2026 21:56:16 -0400 Subject: [PATCH 1/2] feat(worktrees): periodic background `git fetch --all` across all repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a FetchPoller that runs `git fetch --all` on every known repo every 3 minutes so worktrees stay current with their remotes without a manual fetch. The timer always runs; each tick no-ops when disabled or while a prior sweep is still in flight. A boot sweep fires once at startup, and re-enabling the setting fetches immediately rather than waiting for the next tick. Per-repo failures (offline/auth) are swallowed so one bad remote doesn't stop the others. Adds an `autoFetchEnabled` setting (default on) with a toggle under Settings → Worktrees to disable it for all repos. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/build-initial-state.ts | 3 +- src/main/fetch-poller.ts | 63 ++++++++++++++++++++++++++++ src/main/index.ts | 29 +++++++++++++ src/main/persistence.ts | 3 ++ src/main/worktree.ts | 10 +++++ src/renderer/build-backend.ts | 1 + src/renderer/components/Settings.tsx | 20 +++++++++ src/renderer/types.ts | 1 + src/shared/state/settings.test.ts | 14 +++++++ src/shared/state/settings.ts | 10 ++++- 10 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/main/fetch-poller.ts 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..a1fecb08 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. + // The poller's timer keeps running either way; disabled ticks no-op. + if (config.autoFetchEnabled !== false) 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..3bfeae04 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, @@ -2616,6 +2617,25 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: +

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. +

+ +

PR review prompt

Default kickoff prompt sent to Claude when you open a PR as a worktree (or when the MCP{' '} 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 From 46cd2c72846f34a1a2a50284606f134b00f0e0d8 Mon Sep 17 00:00:00 2001 From: Sterling Greene Date: Thu, 4 Jun 2026 22:15:19 -0400 Subject: [PATCH 2/2] refactor(settings): move auto-fetch toggle under the worktree-base options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Places the "Auto-fetch remotes" toggle directly beneath the "Branch from the latest remote main" radio group (both are remote-related, global-only settings) instead of next to the Claude-permissions sharing toggle. Also drops a redundant enabled-check in the setAutoFetchEnabled IPC handler — fetchAll() already no-ops while disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/index.ts | 4 +-- src/renderer/components/Settings.tsx | 42 +++++++++++++++------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index a1fecb08..6ed99881 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1821,8 +1821,8 @@ function registerIpcHandlers(): void { payload: config.autoFetchEnabled !== false }) // Re-enabling shouldn't wait up to 3m for the next tick — fetch now. - // The poller's timer keeps running either way; disabled ticks no-op. - if (config.autoFetchEnabled !== false) void fetchPoller.fetchAll() + // fetchAll() itself no-ops while disabled, so this is safe either way. + void fetchPoller.fetchAll() return true }) diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 3bfeae04..80acd14a 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -2317,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 && ( @@ -2617,25 +2640,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: -

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. -

- -

PR review prompt

Default kickoff prompt sent to Claude when you open a PR as a worktree (or when the MCP{' '}