fix: dedupe multi-vault auto sync fetches
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
|
||||
@@ -73,6 +73,10 @@ interface PushOutcome {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface SyncBudgetOptions {
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
interface UpdatedVaultRefreshOptions {
|
||||
outcomes: PullOutcome[]
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
@@ -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<Map<string, number>>(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 }
|
||||
|
||||
Reference in New Issue
Block a user