Sync folder structure via Git Sync (folders.json), plus bugs found testing it
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
conversationId→folderId assignments, imported before conversation
files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
guarded the same way conversation-orphan cleanup already is against
an empty/stale manifest wiping everything.
Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
directly into the database — only reloaded on launch or when the
advanced conversation list closed, with no equivalent hook for the
Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
as an untracked file that then collided with the remote's tracked
copy on the next pull ("untracked working tree files would be
overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
import, so any conversation already synced to a machine before this
feature existed never got filed — which in practice is every
conversation on a second machine, not an edge case. Now backfills
a folder assignment for existing conversations that aren't filed
anywhere locally yet, without clobbering an already-set folderId.
Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
This commit is contained in:
@@ -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<String>,
|
||||
localFolderIds: Set<String>
|
||||
) -> [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
|
||||
|
||||
Reference in New Issue
Block a user