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.
236 lines
9.6 KiB
Swift
236 lines
9.6 KiB
Swift
//
|
|
// GitSyncServiceTests.swift
|
|
// oAITests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Confab
|
|
|
|
@Suite("GitSyncService pure helpers")
|
|
struct GitSyncServiceTests {
|
|
|
|
private var service: GitSyncService { GitSyncService.shared }
|
|
|
|
// MARK: - convertToSSH
|
|
|
|
@Test("Already-SSH URLs pass through unchanged")
|
|
func convertToSSHAlreadySSH() {
|
|
#expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git")
|
|
}
|
|
|
|
@Test("HTTPS URL converts to SSH form")
|
|
func convertToSSHFromHTTPS() {
|
|
#expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git")
|
|
}
|
|
|
|
@Test("HTTP URL converts to SSH form")
|
|
func convertToSSHFromHTTP() {
|
|
#expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git")
|
|
}
|
|
|
|
@Test("Malformed URL with no path segment is returned unchanged")
|
|
func convertToSSHMalformedNoSlash() {
|
|
#expect(service.convertToSSH("https://onlyhost") == "https://onlyhost")
|
|
}
|
|
|
|
@Test("Unknown scheme is returned unchanged")
|
|
func convertToSSHUnknownScheme() {
|
|
#expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo")
|
|
}
|
|
|
|
// MARK: - injectCredentials
|
|
|
|
@Test("Injects username and password into an HTTPS URL")
|
|
func injectCredentialsHTTPS() {
|
|
let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret")
|
|
#expect(result == "https://rune:secret@gitlab.pm/rune/repo.git")
|
|
}
|
|
|
|
@Test("Non-HTTPS URLs are returned unchanged (credentials not injected)")
|
|
func injectCredentialsNonHTTPS() {
|
|
let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret")
|
|
#expect(result == "git@gitlab.pm:rune/repo.git")
|
|
}
|
|
|
|
// MARK: - sanitizeFilename
|
|
|
|
@Test("Strips filesystem-invalid characters, replacing each with a dash")
|
|
func sanitizeFilenameStripsInvalidChars() {
|
|
#expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j")
|
|
}
|
|
|
|
@Test("Leaves an already-valid filename untouched")
|
|
func sanitizeFilenameValidInput() {
|
|
#expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15")
|
|
}
|
|
|
|
// MARK: - detectSecretsInText
|
|
|
|
@Test("Detects an OpenAI-style API key")
|
|
func detectSecretsOpenAIKey() {
|
|
let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456"
|
|
#expect(service.detectSecretsInText(text).contains("OpenAI Key"))
|
|
}
|
|
|
|
@Test("Detects a GitHub personal access token")
|
|
func detectSecretsGitHubToken() {
|
|
let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890"
|
|
#expect(service.detectSecretsInText(text).contains("Access Token"))
|
|
}
|
|
|
|
@Test("Plain conversational text has no detected secrets")
|
|
func detectSecretsCleanText() {
|
|
#expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty)
|
|
}
|
|
|
|
@Test("Does not flag an OpenAI-shaped string one character short of the length threshold")
|
|
func detectSecretsJustBelowThreshold() {
|
|
// "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-"
|
|
let shortKey = "sk-" + String(repeating: "a", count: 31)
|
|
#expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key"))
|
|
}
|
|
|
|
// MARK: - extractProvider
|
|
|
|
@Test("Recognizes github.com, gitlab.com, and gitea URLs by name")
|
|
func extractProviderRecognizesKnownHosts() {
|
|
#expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub")
|
|
#expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab")
|
|
#expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea")
|
|
}
|
|
|
|
@Test("Falls back to a generic label for an unrecognized host")
|
|
func extractProviderFallsBackForUnknownHost() {
|
|
#expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository")
|
|
}
|
|
|
|
// MARK: - orphanedExportFilenames
|
|
|
|
private func makeExportMarkdown(id: String, name: String = "Test") -> String {
|
|
ConversationExport(
|
|
id: id,
|
|
name: name,
|
|
createdAt: Date(),
|
|
updatedAt: Date(),
|
|
primaryModel: nil,
|
|
messages: [.init(role: "user", content: "hi", timestamp: Date(), tokens: nil, cost: nil, modelId: nil)]
|
|
).toMarkdown()
|
|
}
|
|
|
|
@Test("A file whose ID still exists locally is not orphaned")
|
|
func keepsFilesForExistingConversations() {
|
|
let id = UUID().uuidString
|
|
let files = [(filename: "chat.md", markdown: makeExportMarkdown(id: id))]
|
|
let orphans = GitSyncService.orphanedExportFilenames(currentIds: [id], files: files)
|
|
#expect(orphans.isEmpty)
|
|
}
|
|
|
|
@Test("A file whose ID no longer exists locally is orphaned")
|
|
func flagsFilesForDeletedConversations() {
|
|
let deletedId = UUID().uuidString
|
|
let keptId = UUID().uuidString
|
|
let files = [
|
|
(filename: "deleted.md", markdown: makeExportMarkdown(id: deletedId)),
|
|
(filename: "kept.md", markdown: makeExportMarkdown(id: keptId)),
|
|
]
|
|
let orphans = GitSyncService.orphanedExportFilenames(currentIds: [keptId], files: files)
|
|
#expect(orphans == ["deleted.md"])
|
|
}
|
|
|
|
@Test("Files that fail to parse are left alone rather than deleted")
|
|
func unparsableFilesAreNotFlaggedAsOrphans() {
|
|
let files = [(filename: "not-a-conversation.md", markdown: "not a valid export file")]
|
|
let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files)
|
|
#expect(orphans.isEmpty)
|
|
}
|
|
|
|
@Test("Empty file list produces no orphans")
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|