Fix Git Sync wiping the entire repo when a fresh clone's DB is empty

exportAllConversations()'s orphan-cleanup treated "zero local
conversations" as "every synced conversation was deleted," so a
just-cloned repo on a new machine could get emptied and pushed before
the post-clone import ever ran. orphanedExportFilenames() now returns
no orphans when the local ID set is empty, and cloneRepository()
imports immediately after cloning to close the window entirely.
This commit is contained in:
2026-08-01 10:50:46 +02:00
parent 77223536e9
commit ae3de3d927
2 changed files with 28 additions and 1 deletions
+14 -1
View File
@@ -68,6 +68,11 @@ class GitSyncService {
_ = try await runGit(["clone", url, localPath]) _ = try await runGit(["clone", url, localPath])
syncStatus.isCloned = true 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() await updateStatus()
} }
@@ -202,7 +207,15 @@ class GitSyncService {
currentIds: Set<String>, currentIds: Set<String>,
files: [(filename: String, markdown: String)] files: [(filename: String, markdown: String)]
) -> [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 } guard let export = try? ConversationExport.fromMarkdown(file.markdown) else { return nil }
return currentIds.contains(export.id) ? nil : file.filename return currentIds.contains(export.id) ? nil : file.filename
} }
+14
View File
@@ -151,4 +151,18 @@ struct GitSyncServiceTests {
func emptyFileListProducesNoOrphans() { func emptyFileListProducesNoOrphans() {
#expect(GitSyncService.orphanedExportFilenames(currentIds: [UUID().uuidString], files: []).isEmpty) #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)
}
} }