diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 52ba9dc..fb28cde 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -68,6 +68,11 @@ class GitSyncService { _ = try await runGit(["clone", url, localPath]) syncStatus.isCloned = true + // Import immediately so this machine's DB is never left empty after a clone — + // an empty DB is what makes the next export think every existing conversation + // was deleted (see exportAllConversations's orphan-cleanup guard). + _ = try? await importAllConversations() + await updateStatus() } @@ -202,7 +207,15 @@ class GitSyncService { currentIds: Set, files: [(filename: String, markdown: String)] ) -> [String] { - files.compactMap { file in + // A locally-empty conversation list is indistinguishable here from "nothing has been + // imported into this machine's DB yet" (e.g. right after a fresh clone). Treating it as + // "every existing file was deleted" wiped a user's entire sync repo in production: clone + // completed, an auto-sync fired before the post-clone import finished, every synced + // conversation looked orphaned, and the deletion got committed and pushed. Skipping + // cleanup here means a genuine last-conversation deletion won't propagate until another + // conversation exists locally — a far smaller cost than mass data loss. + guard !currentIds.isEmpty else { return [] } + return files.compactMap { file in guard let export = try? ConversationExport.fromMarkdown(file.markdown) else { return nil } return currentIds.contains(export.id) ? nil : file.filename } diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index fb97c59..725cfe4 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -151,4 +151,18 @@ struct GitSyncServiceTests { func emptyFileListProducesNoOrphans() { #expect(GitSyncService.orphanedExportFilenames(currentIds: [UUID().uuidString], files: []).isEmpty) } + + @Test("Empty local conversation list never flags files as orphaned, even when files exist") + func emptyCurrentIdsNeverOrphansExistingFiles() { + // Regression test: this is the exact shape of the production bug. A fresh clone (or any + // moment where the local DB hasn't caught up yet) has zero local conversations, while the + // sync repo still has every previously-synced file on disk. Without the guard, all of them + // used to be flagged orphaned and deleted+pushed, wiping the whole sync repo. + let files = [ + (filename: "a.md", markdown: makeExportMarkdown(id: UUID().uuidString)), + (filename: "b.md", markdown: makeExportMarkdown(id: UUID().uuidString)), + ] + let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files) + #expect(orphans.isEmpty) + } }