Merge branch 'main' into pr-735

This commit is contained in:
github-actions[bot]
2026-05-25 09:24:05 +00:00
committed by GitHub
3 changed files with 74 additions and 13 deletions

View File

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

View File

@@ -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)

View File

@@ -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 }