diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings
index eaccd3e..7e88e58 100644
--- a/oAI/Localizable.xcstrings
+++ b/oAI/Localizable.xcstrings
@@ -8059,36 +8059,36 @@
}
}
},
- "Initialize Repository" : {
+ "Clone Repository" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Initialiser repository"
+ "value" : "Klon repository"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Repository initialisieren"
+ "value" : "Repository klonen"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Initialiser le dépôt"
+ "value" : "Cloner le dépôt"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Initialiser repositorium"
+ "value" : "Klon repositorium"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Initiera förvar"
+ "value" : "Klona förvar"
}
}
}
diff --git a/oAI/Models/Folder.swift b/oAI/Models/Folder.swift
index 29f4f6a..a51fe98 100644
--- a/oAI/Models/Folder.swift
+++ b/oAI/Models/Folder.swift
@@ -29,19 +29,22 @@ struct Folder: Identifiable, Codable, Sendable {
var sortOrder: Int
let createdAt: Date
var parentId: UUID?
+ var updatedAt: Date
nonisolated init(
id: UUID = UUID(),
name: String,
sortOrder: Int = 0,
createdAt: Date = Date(),
- parentId: UUID? = nil
+ parentId: UUID? = nil,
+ updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.sortOrder = sortOrder
self.createdAt = createdAt
self.parentId = parentId
+ self.updatedAt = updatedAt ?? createdAt
}
}
diff --git a/oAI/Models/SyncModels.swift b/oAI/Models/SyncModels.swift
index fbaed12..eebcdba 100644
--- a/oAI/Models/SyncModels.swift
+++ b/oAI/Models/SyncModels.swift
@@ -68,6 +68,24 @@ struct SyncStatus: Equatable {
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
}
+/// Serialized as `folders.json` at the sync repo root. Unlike conversation exports, this is a
+/// manifest meant to be machine-written/read only (the repo's README already warns against manual
+/// edits), so plain JSON is used rather than the hand-rolled markdown format — no need for that
+/// format's human-readability tradeoffs here.
+nonisolated struct FolderSyncManifest: Codable {
+ nonisolated struct FolderEntry: Codable {
+ let id: String
+ let name: String
+ let parentId: String?
+ let createdAt: Date
+ let updatedAt: Date
+ }
+
+ var folders: [FolderEntry]
+ /// conversationId -> folderId. Only present for conversations actually filed in a folder.
+ var assignments: [String: String]
+}
+
nonisolated struct ConversationExport {
let id: String
let name: String
diff --git a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html
index 6195e5c..61edd79 100644
--- a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html
+++ b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html
@@ -1615,7 +1615,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See Unsaved Changes & Crash Recovery.
Manual Sync:
- - Initialize Repository - Clone repository for first-time setup
+ - Clone Repository - Clone repository for first-time setup
- Sync Now - Full sync (export + pull + import + push)
diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift
index 631d2bb..ebf5d04 100644
--- a/oAI/Services/DatabaseService.swift
+++ b/oAI/Services/DatabaseService.swift
@@ -46,6 +46,7 @@ struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var sortOrder: Int
var createdAt: String
var parentId: String?
+ var updatedAt: String?
}
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -381,6 +382,16 @@ final class DatabaseService: Sendable {
try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"])
}
+ migrator.registerMigration("v11") { db in
+ // Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in
+ // folder state from another machine — without a timestamp there's no way to tell whose
+ // version of a rename is newer. Backfilled from createdAt for existing rows.
+ try db.alter(table: "folders") { t in
+ t.add(column: "updatedAt", .text)
+ }
+ try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
+ }
+
return migrator
}
@@ -632,7 +643,8 @@ final class DatabaseService: Sendable {
name: folder.name,
sortOrder: folder.sortOrder,
createdAt: Self.isoString(from: folder.createdAt),
- parentId: parentId?.uuidString
+ parentId: parentId?.uuidString,
+ updatedAt: Self.isoString(from: folder.updatedAt)
)
try dbQueue.write { db in
try record.insert(db)
@@ -651,8 +663,8 @@ final class DatabaseService: Sendable {
nonisolated func renameFolder(id: UUID, name: String) throws {
try dbQueue.write { db in
try db.execute(
- sql: "UPDATE folders SET name = ? WHERE id = ?",
- arguments: [name, id.uuidString]
+ sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?",
+ arguments: [name, Self.isoString(from: Date()), id.uuidString]
)
}
}
@@ -668,8 +680,38 @@ final class DatabaseService: Sendable {
throw FolderError.wouldCreateCycle
}
}
- try db.execute(sql: "UPDATE folders SET parentId = ? WHERE id = ?",
- arguments: [parentId?.uuidString, id.uuidString])
+ try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?",
+ arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString])
+ }
+ }
+
+ /// Creates or updates a folder with an externally-supplied identity and timestamps, for Git
+ /// Sync import — as opposed to `createFolder`, which is for user-initiated creation and always
+ /// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only
+ /// overwrites name/parentId when `updatedAt` is strictly newer than the local row's.
+ nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws {
+ let existing = try dbQueue.read { db in
+ try FolderRecord.fetchOne(db, key: id.uuidString)
+ }
+ if let existing {
+ guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)),
+ existingUpdatedAt < updatedAt
+ else { return }
+ try dbQueue.write { db in
+ try db.execute(
+ sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?",
+ arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString]
+ )
+ }
+ } else {
+ let record = FolderRecord(
+ id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(),
+ createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString,
+ updatedAt: Self.isoString(from: updatedAt)
+ )
+ try dbQueue.write { db in
+ try record.insert(db)
+ }
}
}
@@ -702,9 +744,12 @@ final class DatabaseService: Sendable {
guard let id = UUID(uuidString: record.id),
let createdAt = Self.isoDate(from: record.createdAt)
else { return nil }
+ // Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11
+ // migration, which backfills every existing row) rather than failing the whole fetch.
+ let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt
return Folder(
id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt,
- parentId: record.parentId.flatMap { UUID(uuidString: $0) }
+ parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt
)
}
diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift
index 4086029..673b7ed 100644
--- a/oAI/Services/GitSyncService.swift
+++ b/oAI/Services/GitSyncService.swift
@@ -196,6 +196,28 @@ class GitSyncService {
log.info("Removed orphaned export for deleted conversation: \(filename)")
}
+ // Export the folder tree + conversation→folder assignments alongside the conversations
+ // themselves, so a fresh machine's import can restore folder structure too. See
+ // upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this.
+ let allFolders = try db.listFolders()
+ let manifest = FolderSyncManifest(
+ folders: allFolders.map {
+ FolderSyncManifest.FolderEntry(
+ id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString,
+ createdAt: $0.createdAt, updatedAt: $0.updatedAt
+ )
+ },
+ assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in
+ conv.folderId.map { (conv.id.uuidString, $0.uuidString) }
+ })
+ )
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let manifestData = try encoder.encode(manifest)
+ try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json"))
+ log.debug("Exported folders.json (\(allFolders.count) folders)")
+
await updateStatus()
}
@@ -221,6 +243,21 @@ class GitSyncService {
}
}
+ /// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids
+ /// that exist locally, returns the local ids that should be deleted (folder was removed
+ /// upstream since the last sync). Same empty-manifest safety guard as
+ /// `orphanedExportFilenames` — an empty manifest is indistinguishable from "haven't imported
+ /// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before
+ /// the first export), so treating it as "delete every local folder" would be exactly the same
+ /// class of mass-deletion bug that hit conversation sync.
+ nonisolated static func orphanedLocalFolderIds(
+ manifestFolderIds: Set,
+ localFolderIds: Set
+ ) -> [String] {
+ guard !manifestFolderIds.isEmpty else { return [] }
+ return localFolderIds.filter { !manifestFolderIds.contains($0) }
+ }
+
/// Import conversations from markdown files
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
try ensureCloned()
@@ -233,6 +270,49 @@ class GitSyncService {
return (0, 0, 0)
}
+ // Import the folder tree + assignments before any conversation, so a brand-new
+ // conversation created below can immediately reference a folder that already exists
+ // locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the
+ // first export) is treated as "no folders to import," not an error.
+ var folderAssignments: [String: String] = [:]
+ let manifestPath = localPath + "/folders.json"
+ if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) {
+ folderAssignments = manifest.assignments
+
+ // Insert parents before children so parentId's foreign key reference (folders.
+ // parentId references folders(id)) is always satisfied.
+ let manifestFolders = manifest.folders.compactMap { entry -> Folder? in
+ guard let id = UUID(uuidString: entry.id) else { return nil }
+ return Folder(
+ id: id, name: entry.name, createdAt: entry.createdAt,
+ parentId: entry.parentId.flatMap { UUID(uuidString: $0) },
+ updatedAt: entry.updatedAt
+ )
+ }
+ for (folder, _) in Folder.orderedTree(from: manifestFolders) {
+ try? db.upsertSyncedFolder(
+ id: folder.id, name: folder.name, parentId: folder.parentId,
+ createdAt: folder.createdAt, updatedAt: folder.updatedAt
+ )
+ }
+
+ // Delete local folders no longer present upstream — reparents their contents up
+ // one level via the existing deleteFolder semantics.
+ let manifestFolderIds = Set(manifest.folders.map { $0.id })
+ let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? [])
+ for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) {
+ if let id = UUID(uuidString: idString) {
+ try? db.deleteFolder(id: id)
+ log.info("Removed local folder no longer present in sync repo: \(idString)")
+ }
+ }
+ log.debug("Imported folders.json (\(manifest.folders.count) folders)")
+ }
+ }
+
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
let mdFiles = files.filter { $0.hasSuffix(".md") }
@@ -254,8 +334,20 @@ class GitSyncService {
// Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) {
- if (try? db.loadConversation(id: existingId)) != nil {
- // Already exists - skip
+ if let (existingConversation, _) = try? db.loadConversation(id: existingId) {
+ // Already exists - skip re-importing its content, but still backfill a
+ // folder assignment if the manifest has one and this conversation isn't
+ // filed anywhere locally yet. Without this, a conversation that was synced
+ // to this machine before folder sync existed (or before it was ever put in
+ // a folder on any machine) would never get filed here — every conversation
+ // in a multi-machine setup already exists locally by the time folders.json
+ // starts carrying assignments, so this isn't an edge case, it's the normal
+ // case. Never overwrites an existing local folderId, so a conversation
+ // already filed (by this machine or a prior import) isn't silently moved.
+ if existingConversation.folderId == nil,
+ let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) {
+ try? db.moveConversation(id: existingId, toFolder: assignedFolderId)
+ }
log.debug("Skipping existing conversation: \(export.name)")
skipped += 1
continue
@@ -282,13 +374,18 @@ class GitSyncService {
)
}
- // Import to database with primaryModel
+ // Import to database with primaryModel, plus its folder assignment (if any) from
+ // folders.json — only applies here at first-import; an existing local conversation
+ // that's later moved to a different folder on another machine doesn't get updated,
+ // matching how its content/name aren't updated either once already imported.
let conversationId = UUID(uuidString: export.id) ?? UUID()
+ let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) }
_ = try db.saveConversation(
id: conversationId,
name: export.name,
messages: messages,
- primaryModel: export.primaryModel
+ primaryModel: export.primaryModel,
+ folderId: folderId
)
log.info("Imported: \(export.name)")
imported += 1
@@ -337,23 +434,27 @@ class GitSyncService {
### Export (Automatic)
- Confab saves conversations to its local database
- Auto-sync exports conversations to `conversations/*.md`
+ - Your folder structure (if you organize conversations into folders) is exported to `folders.json`
- Files are committed and pushed to this git repository
### Import (On New Machine)
- Clone this repository on a new machine
- - Confab imports markdown files into its database
- - Your conversation history is restored
+ - Confab imports markdown files and folders.json into its database
+ - Your conversation history and folder structure are restored
### Sync Across Machines
- Machine A: Chat → Auto-save → Export → Push to git
- Machine B: Pull from git → Auto-import → Database updated
- Conversations stay in sync across all machines
+ - Folder renames/moves also sync between machines; a conversation's folder is only set
+ the first time it's imported onto a new machine
## File Structure
```
/
├── README.md # This file
+ ├── folders.json # Your folder structure (auto-managed, don't edit)
└── conversations/ # Your conversations
├── conversation-1.md
├── conversation-2.md
diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift
index 8c35ea8..fa16e59 100644
--- a/oAI/Views/Main/SidebarView.swift
+++ b/oAI/Views/Main/SidebarView.swift
@@ -231,6 +231,11 @@ struct SidebarView: View {
// conversation list modal live in its own @State — refresh ours once it closes.
if !isShowing { loadData() }
}
+ .onChange(of: chatViewModel.showSettings) { _, isShowing in
+ // Git Sync (Settings → Sync) can import conversations/folders directly into the
+ // database — refresh ours once the sheet closes so they show up without a relaunch.
+ if !isShowing { loadData() }
+ }
}
@ViewBuilder
diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift
index ca68823..ed69256 100644
--- a/oAI/Views/Screens/SettingsView.swift
+++ b/oAI/Views/Screens/SettingsView.swift
@@ -1737,7 +1737,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
} else {
Image(systemName: "arrow.down.circle")
}
- Text("Initialize Repository")
+ Text("Clone Repository")
}
.frame(minWidth: 160)
}
@@ -3023,18 +3023,22 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
syncTestResult = nil
do {
- // Step 1: Export all conversations
- syncTestResult = "Exporting conversations..."
- try await gitSync.exportAllConversations()
-
- // Step 2: Pull from remote
+ // Step 1: Pull from remote first, into a working tree that hasn't been touched by
+ // this round's export yet. Exporting before pulling can write files (e.g.
+ // folders.json, which every machine writes to the same path) that git then refuses
+ // to merge over: "untracked working tree files would be overwritten by merge".
syncTestResult = "Pulling changes..."
try await gitSync.pull()
- // Step 3: Import any new conversations
+ // 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()
diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift
index 5d58eb3..1f26b2d 100644
--- a/oAITests/DatabaseServiceTests.swift
+++ b/oAITests/DatabaseServiceTests.swift
@@ -237,6 +237,98 @@ struct DatabaseServiceFolderTests {
#expect(db.columnNames(in: "folders").contains("parentId"))
}
+ @Test("v11 adds updatedAt to folders")
+ func v11AddsUpdatedAt() {
+ let db = DatabaseService.makeInMemory()
+ #expect(db.columnNames(in: "folders").contains("updatedAt"))
+ }
+
+ @Test("renameFolder bumps updatedAt")
+ func renameFolderBumpsUpdatedAt() throws {
+ let db = DatabaseService.makeInMemory()
+ let folder = try db.createFolder(name: "Work")
+ // Round-trip through the DB for the "before" value too, so both sides go through the same
+ // fractional-seconds truncation as the "after" read below — comparing a raw in-memory
+ // Date() (full precision) against a DB-round-tripped one can flake when both timestamps
+ // land in the same millisecond window.
+ let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == folder.id })?.updatedAt)
+
+ try db.renameFolder(id: folder.id, name: "Projects")
+
+ let updated = try db.listFolders().first(where: { $0.id == folder.id })
+ #expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
+ }
+
+ @Test("moveFolder bumps updatedAt")
+ func moveFolderBumpsUpdatedAt() throws {
+ let db = DatabaseService.makeInMemory()
+ let parent = try db.createFolder(name: "Work")
+ let child = try db.createFolder(name: "Personal")
+ // See renameFolderBumpsUpdatedAt's comment: round-trip through the DB for the "before"
+ // value so it's truncated the same way as the "after" read.
+ let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == child.id })?.updatedAt)
+
+ try db.moveFolder(id: child.id, toParent: parent.id)
+
+ let updated = try db.listFolders().first(where: { $0.id == child.id })
+ #expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt)
+ }
+
+ @Test("upsertSyncedFolder creates a folder that doesn't exist locally yet")
+ func upsertSyncedFolderCreatesNew() throws {
+ let db = DatabaseService.makeInMemory()
+ let id = UUID()
+ let createdAt = Date(timeIntervalSince1970: 1_000)
+ let updatedAt = Date(timeIntervalSince1970: 2_000)
+
+ try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: updatedAt)
+
+ let folders = try db.listFolders()
+ let created = try #require(folders.first(where: { $0.id == id }))
+ #expect(created.name == "Work")
+ #expect(created.parentId == nil)
+ #expect(created.updatedAt == updatedAt)
+ }
+
+ @Test("upsertSyncedFolder is a no-op when the local version is the same age or newer")
+ func upsertSyncedFolderNoOpWhenLocalNotOlder() throws {
+ let db = DatabaseService.makeInMemory()
+ let id = UUID()
+ let createdAt = Date(timeIntervalSince1970: 1_000)
+ let localUpdatedAt = Date(timeIntervalSince1970: 5_000)
+ try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
+
+ // Incoming manifest entry is older than what's already local.
+ let staleIncomingUpdatedAt = Date(timeIntervalSince1970: 2_000)
+ try db.upsertSyncedFolder(id: id, name: "Renamed Elsewhere", parentId: nil, createdAt: createdAt, updatedAt: staleIncomingUpdatedAt)
+
+ let folders = try db.listFolders()
+ let unchanged = try #require(folders.first(where: { $0.id == id }))
+ #expect(unchanged.name == "Work")
+ #expect(unchanged.updatedAt == localUpdatedAt)
+ }
+
+ @Test("upsertSyncedFolder updates name and parent when the incoming version is newer")
+ func upsertSyncedFolderUpdatesWhenIncomingNewer() throws {
+ let db = DatabaseService.makeInMemory()
+ let id = UUID()
+ let otherParent = try db.createFolder(name: "Other Parent")
+ let createdAt = Date(timeIntervalSince1970: 1_000)
+ let localUpdatedAt = Date(timeIntervalSince1970: 2_000)
+ try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt)
+
+ let newerIncomingUpdatedAt = Date(timeIntervalSince1970: 9_000)
+ try db.upsertSyncedFolder(
+ id: id, name: "Projects", parentId: otherParent.id, createdAt: createdAt, updatedAt: newerIncomingUpdatedAt
+ )
+
+ let folders = try db.listFolders()
+ let updated = try #require(folders.first(where: { $0.id == id }))
+ #expect(updated.name == "Projects")
+ #expect(updated.parentId == otherParent.id)
+ #expect(updated.updatedAt == newerIncomingUpdatedAt)
+ }
+
@Test("createFolder(parentId:) nests the new folder under its parent")
func createFolderWithParent() throws {
let db = DatabaseService.makeInMemory()
diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift
index 5bc12da..9d3211f 100644
--- a/oAITests/GitSyncServiceTests.swift
+++ b/oAITests/GitSyncServiceTests.swift
@@ -165,4 +165,71 @@ struct GitSyncServiceTests {
let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files)
#expect(orphans.isEmpty)
}
+
+ // MARK: - orphanedLocalFolderIds
+
+ @Test("A local folder still present in the manifest is not orphaned")
+ func keepsLocalFoldersStillInManifest() {
+ let id = UUID().uuidString
+ let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [id], localFolderIds: [id])
+ #expect(orphans.isEmpty)
+ }
+
+ @Test("A local folder missing from the manifest is orphaned")
+ func flagsLocalFoldersMissingFromManifest() {
+ let keptId = UUID().uuidString
+ let deletedId = UUID().uuidString
+ let orphans = GitSyncService.orphanedLocalFolderIds(
+ manifestFolderIds: [keptId], localFolderIds: [keptId, deletedId]
+ )
+ #expect(orphans == [deletedId])
+ }
+
+ @Test("An empty manifest never orphans existing local folders")
+ func emptyManifestNeverOrphansLocalFolders() {
+ // Same safety guard as orphanedExportFilenames: an empty manifest is indistinguishable
+ // from "folders.json hasn't been imported yet" (older sync repo, or a fresh clone before
+ // the first export), so treating it as "delete every local folder" would repeat the exact
+ // class of mass-deletion bug that hit conversation sync.
+ let orphans = GitSyncService.orphanedLocalFolderIds(
+ manifestFolderIds: [], localFolderIds: [UUID().uuidString, UUID().uuidString]
+ )
+ #expect(orphans.isEmpty)
+ }
+
+ @Test("Empty local folder list produces no orphans")
+ func emptyLocalFolderListProducesNoOrphans() {
+ let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [UUID().uuidString], localFolderIds: [])
+ #expect(orphans.isEmpty)
+ }
+
+ // MARK: - FolderSyncManifest round-trip
+
+ @Test("FolderSyncManifest round-trips through JSON encode/decode")
+ func folderSyncManifestRoundTrips() throws {
+ let folderId = UUID().uuidString
+ let conversationId = UUID().uuidString
+ let manifest = FolderSyncManifest(
+ folders: [
+ FolderSyncManifest.FolderEntry(
+ id: folderId, name: "Work", parentId: nil,
+ createdAt: Date(timeIntervalSince1970: 1_000), updatedAt: Date(timeIntervalSince1970: 2_000)
+ )
+ ],
+ assignments: [conversationId: folderId]
+ )
+
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+
+ let data = try encoder.encode(manifest)
+ let decoded = try decoder.decode(FolderSyncManifest.self, from: data)
+
+ #expect(decoded.folders.count == 1)
+ #expect(decoded.folders.first?.id == folderId)
+ #expect(decoded.folders.first?.name == "Work")
+ #expect(decoded.assignments[conversationId] == folderId)
+ }
}