Fix Git Sync race between startup pull and auto-sync export

syncOnStartup() (pull+import, fired at launch) and autoSync() (export+push,
debounced off chat activity) ran as fully independent, uncoordinated Tasks
with no mutual exclusion. A user launching the app and chatting right away
could hit autoSync's export mid-pull, leaving a freshly-written untracked
file that the pull then refuses to merge over — the same failure class as
the earlier folders.json bug, now much more likely to surface widely since
folders.json/notes.json are brand new for every existing sync repo.

Adds a shared isSyncing guard across all three entry points (syncOnStartup
skips if busy, autoSync waits for a clear slot, syncNow throws
.syncInProgress) and moves Sync Now's pull/import/export/push orchestration
out of SettingsView into GitSyncService.syncNow(), where the guard can
actually protect it.
This commit is contained in:
2026-08-04 08:52:48 +02:00
parent f4086c2563
commit 125e1698f7
3 changed files with 68 additions and 27 deletions
+3
View File
@@ -39,6 +39,7 @@ enum SyncError: LocalizedError {
case repoNotCloned case repoNotCloned
case secretsDetected([String]) case secretsDetected([String])
case parseError(String) case parseError(String)
case syncInProgress
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@@ -56,6 +57,8 @@ enum SyncError: LocalizedError {
return "Secrets detected in conversations: \(secrets.joined(separator: ", ")). Remove before syncing." return "Secrets detected in conversations: \(secrets.joined(separator: ", ")). Remove before syncing."
case .parseError(let message): case .parseError(let message):
return "Failed to parse conversation: \(message)" return "Failed to parse conversation: \(message)"
case .syncInProgress:
return "A sync is already in progress. Try again in a moment."
} }
} }
} }
+60 -4
View File
@@ -630,6 +630,20 @@ class GitSyncService {
return return
} }
// Guard against racing autoSync()/syncNow() on the same working tree this method and
// autoSync() are both fired from independent, uncoordinated Tasks (this one at app launch,
// autoSync() debounced off chat activity), so without this a pull here could run while
// autoSync() is mid-export, leaving a freshly-written untracked file (folders.json,
// notes.json) that the pull then refuses to merge over: "untracked working tree files
// would be overwritten by merge." Skipping outright (not waiting) is fine here since
// startup sync is a one-time best-effort fetch, not something the user is blocked on.
guard !isSyncing else {
log.debug("Skipping startup sync (another sync already in progress)")
return
}
isSyncing = true
defer { isSyncing = false }
log.info("Running startup sync (pull + import)...") log.info("Running startup sync (pull + import)...")
do { do {
@@ -668,6 +682,12 @@ class GitSyncService {
// Schedule new sync with 5 second delay // Schedule new sync with 5 second delay
pendingSyncTask = Task { pendingSyncTask = Task {
// Tracks whether *this* task is the one holding isSyncing, so the catch block below
// only ever releases a lock it actually acquired without this, a cancellation while
// still waiting in the loop below (i.e. before this task owns the lock at all) would
// incorrectly clear isSyncing out from under whichever other sync is still running.
var acquiredLock = false
do { do {
// Wait for debounce period // Wait for debounce period
try await Task.sleep(for: .seconds(5)) try await Task.sleep(for: .seconds(5))
@@ -675,11 +695,23 @@ class GitSyncService {
// Check if cancelled during sleep // Check if cancelled during sleep
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
// Wait for any other sync (startup pull, manual Sync Now) already in flight to
// finish rather than racing it on the same working tree see syncOnStartup()'s
// guard for what goes wrong otherwise. Waiting (not skipping) here, since
// auto-sync is how local changes actually reach the remote; silently dropping this
// round could leave a push pending indefinitely if nothing else triggers autoSync
// again soon.
while await MainActor.run(body: { isSyncing }) {
guard !Task.isCancelled else { return }
try await Task.sleep(for: .milliseconds(500))
}
// Set syncing state // Set syncing state
await MainActor.run { await MainActor.run {
isSyncing = true isSyncing = true
lastSyncError = nil lastSyncError = nil
} }
acquiredLock = true
log.info("Auto-sync starting (export + push)...") log.info("Auto-sync starting (export + push)...")
@@ -694,14 +726,17 @@ class GitSyncService {
isSyncing = false isSyncing = false
syncStatus.lastSyncTime = Date() syncStatus.lastSyncTime = Date()
} }
acquiredLock = false
log.info("Auto-sync completed successfully") log.info("Auto-sync completed successfully")
} catch { } catch {
// Error // Error only release the lock if this task actually acquired it
await MainActor.run { if acquiredLock {
isSyncing = false await MainActor.run {
lastSyncError = error.localizedDescription isSyncing = false
lastSyncError = error.localizedDescription
}
} }
log.error("Auto-sync failed: \(error.localizedDescription)") log.error("Auto-sync failed: \(error.localizedDescription)")
@@ -712,6 +747,27 @@ class GitSyncService {
await pendingSyncTask?.value await pendingSyncTask?.value
} }
/// Manual full sync (the Settings Sync "Sync Now" button): pull import export push,
/// in that order so the working tree is fully merged before Confab writes its own files back
/// out (see exportAllConversations's ordering note). Throws `.syncInProgress` rather than
/// racing autoSync()/syncOnStartup() if either is already running on the same working tree
/// same class of bug as the "untracked working tree files" failure those two guard against.
func syncNow() async throws -> (imported: Int, skipped: Int) {
guard !isSyncing else {
throw SyncError.syncInProgress
}
isSyncing = true
defer { isSyncing = false }
try await pull()
let result = try await importAllConversations()
try await exportAllConversations()
try await push()
await updateStatus()
return (result.imported, result.skipped)
}
// MARK: - Secret Scanning // MARK: - Secret Scanning
/// Scan for API keys and secrets in conversations /// Scan for API keys and secrets in conversations
+5 -23
View File
@@ -3039,31 +3039,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
private func syncNow() async { private func syncNow() async {
isSyncing = true isSyncing = true
syncTestResult = nil syncTestResult = "Syncing..."
do { do {
// Step 1: Pull from remote first, into a working tree that hasn't been touched by // Orchestration (pull import export push) and the guard against racing
// this round's export yet. Exporting before pulling can write files (e.g. // autoSync()/syncOnStartup() on the same working tree both live in GitSyncService now
// folders.json, which every machine writes to the same path) that git then refuses // see its syncNow() for why.
// to merge over: "untracked working tree files would be overwritten by merge". let result = try await gitSync.syncNow()
syncTestResult = "Pulling changes..."
try await gitSync.pull()
// Step 2: Import any new/updated conversations and folders from what was just pulled
syncTestResult = "Importing conversations..."
let result = try await gitSync.importAllConversations()
// Step 3: Export now safe, re-derives working tree files from the local DB state,
// which already reflects whatever was just imported plus this machine's own changes
syncTestResult = "Exporting conversations..."
try await gitSync.exportAllConversations()
// Step 4: Push to remote
syncTestResult = "Pushing changes..."
try await gitSync.push()
// Success
await gitSync.updateStatus()
syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped" syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped"
} catch { } catch {
syncTestResult = "✗ Sync failed: \(error.localizedDescription)" syncTestResult = "✗ Sync failed: \(error.localizedDescription)"