From 2a3589181e88dd0430853431c127791b2bf0757e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 25 May 2026 11:09:49 +0200 Subject: [PATCH] fix: dedupe multi-vault auto sync fetches --- docs/ABSTRACTIONS.md | 2 ++ src/hooks/useAutoSync.test.ts | 38 ++++++++++++++++++++++++++++ src/hooks/useAutoSync.ts | 47 +++++++++++++++++++++++++---------- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index f3a21628..a8e6cb76 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -509,6 +509,8 @@ interface PulseCommit { `useAutoSync` hook handles automatic git sync across every active Git repository: - Configurable interval (from app settings: `auto_pull_interval_minutes`) - Pulls the active repository set concurrently on launch, focus, interval, and manual sync +- Budgets automatic launch/focus/interval pulls per repository with a short cooldown so focus or low interval settings do not repeat network Git work immediately after a recent sync; manual sync bypasses this budget +- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it - Pushes the active repository set during divergence recovery - Awaits the post-pull vault refreshes so toasts land after note-list state is fresh - Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts index fea1f079..1ac0a22c 100644 --- a/src/hooks/useAutoSync.test.ts +++ b/src/hooks/useAutoSync.test.ts @@ -26,6 +26,10 @@ function conflict(files: string[]): GitPullResult { return { status: 'conflict', message: `Merge conflict in ${files.length} file(s)`, updatedFiles: [], conflictFiles: files } } +function commandCalls(command: string) { + return mockInvokeFn.mock.calls.filter((call: unknown[]) => call[0] === command) +} + describe('useAutoSync', () => { const onVaultUpdated = vi.fn() const onConflict = vi.fn() @@ -71,6 +75,40 @@ describe('useAutoSync', () => { }) }) + it('dedupes automatic mount, focus, and interval remote-status refreshes within cooldown', async () => { + const now = vi.spyOn(Date, 'now') + let clock = 1_000_000 + now.mockImplementation(() => clock) + + try { + renderSync(0.001, true, ['/Users/luca/Laputa', '/Users/luca/Work']) + + await waitFor(() => { + expect(commandCalls('git_pull')).toHaveLength(2) + expect(commandCalls('git_remote_status')).toHaveLength(2) + }) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(commandCalls('git_remote_status')).toHaveLength(2) + + mockInvokeFn.mockClear() + clock += 5_000 + + await act(async () => { + window.dispatchEvent(new Event('focus')) + await new Promise(resolve => setTimeout(resolve, 100)) + }) + + expect(commandCalls('git_pull')).toHaveLength(0) + expect(commandCalls('git_remote_status')).toHaveLength(0) + } finally { + now.mockRestore() + } + }) + it('does not call git operations when disabled', async () => { const { result } = renderSync(5, false) diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index 0eca2b6e..bdefe1ef 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -6,7 +6,7 @@ import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, Syn import { trackEvent } from '../lib/telemetry' const DEFAULT_INTERVAL_MS = 5 * 60_000 -const FOCUS_COOLDOWN_MS = 30_000 +const AUTO_SYNC_COOLDOWN_MS = 30_000 type MaybePromise = void | Promise @@ -73,6 +73,10 @@ interface PushOutcome { vaultPath: string } +interface SyncBudgetOptions { + force?: boolean +} + interface UpdatedVaultRefreshOptions { outcomes: PullOutcome[] callbacksRef: MutableRefObject @@ -343,19 +347,18 @@ function useAutoSyncLifecycle(options: { if (!enabled) return void checkExistingConflicts().then(hasConflicts => { - if (!hasConflicts) void performPull() + if (hasConflicts) { + void refreshRemoteStatus() + return + } + void performPull() }) - void refreshRemoteStatus() }, [checkExistingConflicts, enabled, performPull, refreshRemoteStatus]) - const lastPullTimeRef = useRef(0) useEffect(() => { if (!enabled) return const handleFocus = () => { - const now = Date.now() - if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return - lastPullTimeRef.current = now void performPull() } window.addEventListener('focus', handleFocus) @@ -401,6 +404,24 @@ export function useAutoSync({ const resolveTargetVaultPaths = useCallback((targetVaultPath?: string) => ( targetVaultPath ? uniqueVaultPaths([targetVaultPath]) : targetVaultPaths ), [targetVaultPaths]) + const lastAutoSyncStartedAtRef = useRef>(new Map()) + const resolveBudgetedTargetVaultPaths = useCallback(( + targetVaultPath?: string, + options: SyncBudgetOptions = {}, + ) => { + const resolvedPaths = resolveTargetVaultPaths(targetVaultPath) + const now = Date.now() + const duePaths = options.force + ? resolvedPaths + : resolvedPaths.filter((path) => { + const lastStartedAt = lastAutoSyncStartedAtRef.current.get(path) + return lastStartedAt === undefined || now - lastStartedAt >= AUTO_SYNC_COOLDOWN_MS + }) + for (const path of duePaths) { + lastAutoSyncStartedAtRef.current.set(path, now) + } + return duePaths + }, [resolveTargetVaultPaths]) const refreshRemoteStatus = useRemoteStatusRefresher(setRemoteStatus) const checkExistingConflicts = useConflictChecker(setSyncStatus, setConflictFiles, setConflictVaultPath, callbacksRef) const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo) @@ -413,9 +434,9 @@ export function useAutoSync({ [refreshRemoteStatus, targetVaultPaths], ) - const performPull = useCallback(async (targetVaultPath?: string) => { + const performPull = useCallback(async (targetVaultPath?: string, options: SyncBudgetOptions = {}) => { if (!enabled) return - const pullVaultPaths = resolveTargetVaultPaths(targetVaultPath) + const pullVaultPaths = resolveBudgetedTargetVaultPaths(targetVaultPath, options) if (pullVaultPaths.length === 0) return await runSyncTask({ @@ -465,12 +486,12 @@ export function useAutoSync({ void refreshRemoteStatus(pullVaultPaths) }, }) - }, [enabled, resolveTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) + }, [enabled, resolveBudgetedTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) /** Pull from remote, then auto-push if successful. Used for divergence recovery. */ const pullAndPush = useCallback(async (targetVaultPath?: string) => { if (!enabled) return - const pullVaultPaths = resolveTargetVaultPaths(targetVaultPath) + const pullVaultPaths = resolveBudgetedTargetVaultPaths(targetVaultPath, { force: true }) if (pullVaultPaths.length === 0) return await runSyncTask({ @@ -536,7 +557,7 @@ export function useAutoSync({ void refreshRemoteStatus(pullVaultPaths) }, }) - }, [enabled, resolveTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) + }, [enabled, resolveBudgetedTargetVaultPaths, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus]) const handlePushRejected = useCallback(() => { setSyncStatus('pull_required') @@ -557,7 +578,7 @@ export function useAutoSync({ if (!enabled) return trackEvent('sync_triggered') - void performPull(targetVaultPath) + void performPull(targetVaultPath, { force: true }) }, [enabled, performPull]) return { syncStatus, lastSyncTime, conflictFiles, conflictVaultPath, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }