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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user