Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/build-initial-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
63 changes: 63 additions & 0 deletions src/main/fetch-poller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
}
29 changes: 29 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3556,6 +3579,12 @@ async function runBoot(): Promise<void> {

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.
Expand Down
3 changes: 3 additions & 0 deletions src/main/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/main/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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: <repo>-worktrees/ alongside the repo */
export function defaultWorktreeDir(repoRoot: string): string {
const repoName = basename(repoRoot)
Expand Down
1 change: 1 addition & 0 deletions src/renderer/build-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
harnessStarred,
worktreeScripts,
shareClaudeSettings,
autoFetchEnabled,
autoUpdateEnabled,
warnBeforeQuitting,
harnessSystemPromptEnabled,
Expand Down Expand Up @@ -2316,6 +2317,29 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
</div>
)}

{scopeRepoRoot === null && (
<>
<h3 className="text-sm font-semibold text-fg-bright mt-6 mb-1">Auto-fetch remotes</h3>
<p className="text-xs text-dim mb-3">
Runs{' '}
<code className="bg-panel-raised px-1 rounded text-xs">git fetch --all</code>{' '}
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.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFetchEnabled}
onChange={(e) => { void backend.setAutoFetchEnabled(e.target.checked) }}
className="accent-current icon-base cursor-pointer" />
<span className="text-sm text-fg">
Periodically fetch all repos in the background
</span>
</label>
</>
)}

<div className="flex items-center justify-between mt-6 mb-1">
<h3 className="text-sm font-semibold text-fg-bright">Default merge strategy</h3>
{scopeRepoRoot === null && reposOverridingKey('mergeStrategy').length > 0 && (
Expand Down
1 change: 1 addition & 0 deletions src/renderer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export interface ElectronAPI {
setWarnBeforeQuitting(enabled: boolean): Promise<boolean>
setExpandedDiagnosticLoggingEnabled(enabled: boolean): Promise<boolean>
setShareClaudeSettings(enabled: boolean): Promise<boolean>
setAutoFetchEnabled(enabled: boolean): Promise<boolean>
setHarnessSystemPromptEnabled(enabled: boolean): Promise<boolean>
setHarnessSystemPrompt(prompt: string): Promise<boolean>
setHarnessSystemPromptMain(prompt: string): Promise<boolean>
Expand Down
14 changes: 14 additions & 0 deletions src/shared/state/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion src/shared/state/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading