import Foundation // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of Confab. // // Confab is licensed under the PolyForm Noncommercial License 1.0.0. // You may use, study, modify, and share it for any noncommercial // purpose. Commercial use — including selling Confab or any part of // it, standalone or bundled into another product or service — // requires a separate commercial license from the copyright holder. // // See the LICENSE file or // for // the full license text. For commercial licensing, contact Rune // Olsen via . import os @Observable class GitSyncService { static let shared = GitSyncService() private let settings = SettingsService.shared private let db = DatabaseService.shared private let log = Logger(subsystem: Log.subsystem, category: "sync") private(set) var syncStatus = SyncStatus() private(set) var isSyncing = false private(set) var lastSyncError: String? // Debounce tracking private var pendingSyncTask: Task? /// A pull failed because a new sync-repo file (folders.json, notes.json, ...) collided with an /// untracked local copy — see parseUntrackedFileConflict(from:). Surfaced to the user via /// GitSyncConflictSheet (wired in ChatView.swift), offering an automatic or manual fix. struct PendingGitConflict: Identifiable { let id = UUID() let files: [String] let rawError: String var canAutoFix: Bool { files.allSatisfy(GitSyncService.isFileSafeToAutoDelete) } } private(set) var pendingGitConflict: PendingGitConflict? = nil private init() { // Check if repository is cloned at initialization (synchronous check) let localPath = expandPath(settings.syncLocalPath) syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git") } // MARK: - Repository Operations /// Test connection to remote repository func testConnection() async throws -> String { let url = try buildAuthenticatedURL() _ = try await runGit(["ls-remote", url]) return "Connected to \(Self.extractProvider(from: settings.syncRepoURL))" } /// Clone repository to local path func cloneRepository() async throws { guard settings.syncConfigured else { throw SyncError.notConfigured } let url = try buildAuthenticatedURL() let localPath = expandPath(settings.syncLocalPath) // Check if already cloned if FileManager.default.fileExists(atPath: localPath + "/.git") { log.info("Repository already cloned at \(localPath)") syncStatus.isCloned = true return } log.info("Cloning repository from \(self.settings.syncRepoURL)") _ = try await runGit(["clone", url, localPath]) 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() } /// Pull latest changes from remote func pull() async throws { try ensureCloned() let localPath = expandPath(settings.syncLocalPath) log.info("Pulling changes from remote") do { _ = try await runGit(["pull", "--ff-only"], cwd: localPath) } catch { // Surface an "untracked working tree files" collision as a recoverable conflict the // user can act on, without changing this function's throw contract — existing callers // (syncOnStartup's non-fatal log, syncNow's error display) are unaffected. Guarded on // pendingGitConflict already being nil so a second pull failure while the sheet is // still showing doesn't replace its content out from under the user. if pendingGitConflict == nil, let files = Self.parseUntrackedFileConflict(from: error.localizedDescription) { pendingGitConflict = PendingGitConflict(files: files, rawError: error.localizedDescription) } throw error } syncStatus.lastSyncTime = Date() await updateStatus() } /// Re-verifies each file is still genuinely untracked (not just trusting the parsed error text) /// immediately before deleting, deletes them, retries pull(), and on success imports so the /// previously-blocked content actually lands. Returns nil on success, an error description on /// failure. Deliberately does not touch pendingGitConflict itself — dismissPendingGitConflict() /// is the sheet's explicit "I'm done looking at this" signal. SwiftUI's .sheet(item:) dismisses /// the instant pendingGitConflict goes nil, so clearing it here would yank the sheet away before /// the user ever sees whether the fix actually worked. func autoResolveUntrackedConflict(_ conflict: PendingGitConflict) async -> String? { guard conflict.canAutoFix else { return "Some of these files can't be safely removed automatically." } let localPath = expandPath(settings.syncLocalPath) for file in conflict.files { guard let status = try? await runGit(["status", "--porcelain", "--", file], cwd: localPath), status.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("??") else { return "\(file) is no longer untracked — leaving it in place rather than risk deleting something else. Try syncing again." } try? FileManager.default.removeItem(at: URL(fileURLWithPath: localPath).appendingPathComponent(file)) } do { try await pull() _ = try await importAllConversations() return nil } catch { return error.localizedDescription } } /// Explicit dismiss for GitSyncConflictSheet — see autoResolveUntrackedConflict's note on why /// the recovery method itself never clears this. func dismissPendingGitConflict() { pendingGitConflict = nil } /// Shown by GitSyncManualFixSheet when the user picks "Fix It Myself" on GitSyncConflictSheet. /// Deliberately in-app text rather than a deep link into the Help Book: NSWorkspace.shared.open() /// silently drops the #fragment for file:// URLs before handing off to the default browser (the /// anchor never survives — confirmed by inspecting location.hash in the opened page, it comes /// back empty), so an anchored Help Book link always lands on the index instead of the relevant /// section. Carrying the conflict's own file list and the real sync path into this sheet is also /// just more useful than generic help-page prose pointing at "the file(s) named in the error". private(set) var pendingManualFixInstructions: PendingGitConflict? = nil /// Swaps GitSyncConflictSheet for GitSyncManualFixSheet — clearing pendingGitConflict here (rather /// than relying on the sheet's own onDismiss) dismisses the first sheet via its .sheet(item:) /// binding while pendingManualFixInstructions immediately presents the second. func showManualFixInstructions(for conflict: PendingGitConflict) { pendingGitConflict = nil pendingManualFixInstructions = conflict } func dismissManualFixInstructions() { pendingManualFixInstructions = nil } /// Push local changes to remote func push(message: String = "Sync from Confab") async throws { try ensureCloned() let localPath = expandPath(settings.syncLocalPath) // 1. Scan for secrets before committing try scanForSecrets(in: localPath) // 2. Add all changes log.info("Adding changes to git") _ = try await runGit(["add", "."], cwd: localPath) // 3. Check if there are changes to commit let status = try await runGit(["status", "--porcelain"], cwd: localPath) guard !status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { log.info("No changes to commit") return } // 4. Commit log.info("Committing changes") _ = try await runGit(["commit", "-m", message], cwd: localPath) // 5. Push log.info("Pushing to remote") // Check if upstream is set, if not set it (for first push to empty repo) do { _ = try await runGit(["push"], cwd: localPath) } catch { // First push might fail if no upstream, try with -u origin HEAD log.info("First push - setting upstream") _ = try await runGit(["push", "-u", "origin", "HEAD"], cwd: localPath) } syncStatus.lastSyncTime = Date() await updateStatus() } // MARK: - Conversation Export/Import /// Export all conversations to markdown files func exportAllConversations() async throws { try ensureCloned() let conversations = try db.listConversations() let localPath = expandPath(settings.syncLocalPath) let conversationsDir = localPath + "/conversations" // Create conversations directory try FileManager.default.createDirectory(atPath: conversationsDir, withIntermediateDirectories: true) // Create README if it doesn't exist try createReadmeIfNeeded() log.info("Exporting \(conversations.count) conversations") for conversation in conversations { // Load full conversation with messages guard let (_, messages) = try db.loadConversation(id: conversation.id) else { log.warning("Could not load conversation \(conversation.id.uuidString)") continue } let export = ConversationExport( id: conversation.id.uuidString, name: conversation.name, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt, primaryModel: conversation.primaryModel, messages: messages.map { msg in ConversationExport.MessageExport( role: msg.role.rawValue, content: msg.content, timestamp: msg.timestamp, tokens: msg.tokens, cost: msg.cost, modelId: msg.modelId ) } ) let markdown = export.toMarkdown() let filename = sanitizeFilename(conversation.name) + ".md" let filepath = conversationsDir + "/" + filename try markdown.write(toFile: filepath, atomically: true, encoding: String.Encoding.utf8) log.debug("Exported: \(filename)") } // Remove files for conversations that no longer exist locally (e.g. deleted since // the last export). Without this, a deletion is never reflected in the sync repo, // so importAllConversations() silently resurrects it on every future pull. let currentIds = Set(conversations.map { $0.id.uuidString }) let existingFiles = (try? FileManager.default.contentsOfDirectory(atPath: conversationsDir)) ?? [] let mdFilesWithContent: [(filename: String, markdown: String)] = existingFiles .filter { $0.hasSuffix(".md") } .compactMap { filename in guard let markdown = try? String(contentsOfFile: conversationsDir + "/" + filename, encoding: .utf8) else { return nil } return (filename, markdown) } for filename in Self.orphanedExportFilenames(currentIds: currentIds, files: mdFilesWithContent) { try? FileManager.default.removeItem(atPath: conversationsDir + "/" + filename) 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)") // Export per-conversation notes (see ConversationNotesService), same manifest + directory // shape as folders.json: notes/ holds the raw file content byte-for-byte (its // embedded **ID** header included), notes.json maps conversationId -> {filename, enabled} // so import can match without parsing file content. let notesDir = localPath + "/notes" try FileManager.default.createDirectory(atPath: notesDir, withIntermediateDirectories: true) var notesEntries: [String: NotesSyncManifest.Entry] = [:] for conversation in conversations { guard let filename = conversation.notesFilename, let content = ConversationNotesService.shared.readRaw(filename: filename) else { continue } try content.write(toFile: notesDir + "/" + filename, atomically: true, encoding: .utf8) notesEntries[conversation.id.uuidString] = NotesSyncManifest.Entry(filename: filename, enabled: conversation.notesEnabled) } let notesManifest = NotesSyncManifest(notes: notesEntries) let notesManifestData = try encoder.encode(notesManifest) try notesManifestData.write(to: URL(fileURLWithPath: localPath + "/notes.json")) log.debug("Exported notes.json (\(notesEntries.count) notes)") // Remove sync-repo note files for conversations that no longer exist locally — same // orphan cleanup and empty-state safety guard as orphanedExportFilenames above. let existingNoteFiles = (try? FileManager.default.contentsOfDirectory(atPath: notesDir)) ?? [] let currentNoteFilenames = Set(notesEntries.values.map { $0.filename }) for filename in Self.orphanedNoteFilenames(currentFilenames: currentNoteFilenames, existingFiles: existingNoteFiles) { try? FileManager.default.removeItem(atPath: notesDir + "/" + filename) log.info("Removed orphaned note file: \(filename)") } await updateStatus() } /// Given the note filenames currently referenced by local conversations and the filenames /// found on disk in the sync repo's notes directory, returns the filenames safe to delete /// because no local conversation references them anymore (conversation deleted, or its notes /// file was replaced). Same empty-state guard as orphanedExportFilenames/orphanedLocalFolderIds /// — an empty currentFilenames set is indistinguishable from "haven't loaded local /// conversations yet" (e.g. right after a fresh clone), so treating it as "every note file was /// orphaned" would repeat the exact class of mass-deletion bug that hit conversation sync. nonisolated static func orphanedNoteFilenames(currentFilenames: Set, existingFiles: [String]) -> [String] { guard !currentFilenames.isEmpty else { return [] } return existingFiles.filter { $0.hasSuffix(".md") && !currentFilenames.contains($0) } } /// Given the current conversation IDs and the (filename, markdown content) pairs found in /// the sync repo's conversations directory, returns the filenames whose export ID doesn't /// match any current conversation — i.e. files safe to delete because their conversation /// was removed from the database since the last export. nonisolated static func orphanedExportFilenames( currentIds: Set, files: [(filename: String, markdown: String)] ) -> [String] { // 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 } return currentIds.contains(export.id) ? nil : file.filename } } /// 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) } } // MARK: - Untracked File Conflict Recovery /// Parses git's "untracked working tree files would be overwritten by merge" pull failure into /// the list of colliding relative paths. Returns nil for any other error (auth, network, a real /// merge conflict) — those aren't what this recovery flow is for. Exact git format: /// "error: The following untracked working tree files would be overwritten by merge:\n\t\n...\nPlease move or remove them before you merge.\nAborting" nonisolated static func parseUntrackedFileConflict(from message: String) -> [String]? { let marker = "untracked working tree files would be overwritten by merge:" guard let markerRange = message.range(of: marker) else { return nil } let lines = message[markerRange.upperBound...].components(separatedBy: "\n") var files: [String] = [] for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { continue } // The file list ends at the first line that isn't an indented filename (git's own // trailing "Please move or remove them..."/"Aborting" lines aren't tab-indented). guard line.hasPrefix("\t") || line.hasPrefix(" ") else { break } files.append(trimmed) } return files.isEmpty ? nil : files } /// Defense in depth for the "Fix It For Me" auto-recovery path: only files this app itself is /// known to write into the sync repo are ever eligible for automatic deletion. Rejects path /// traversal, absolute paths, and anything outside the known shape — an unrecognized file falls /// back to manual recovery only (see PendingGitConflict.canAutoFix). nonisolated static func isFileSafeToAutoDelete(_ relativePath: String) -> Bool { if relativePath == "folders.json" || relativePath == "notes.json" { return true } for prefix in ["conversations/", "notes/"] { guard relativePath.hasPrefix(prefix) else { continue } let rest = relativePath.dropFirst(prefix.count) // Exactly one path segment (no further "/"), and a .md file. return !rest.isEmpty && !rest.contains("/") && rest.hasSuffix(".md") } return false } /// Import conversations from markdown files func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) { try ensureCloned() let localPath = expandPath(settings.syncLocalPath) let conversationsDir = localPath + "/conversations" guard FileManager.default.fileExists(atPath: conversationsDir) else { log.warning("No conversations directory found") 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") } log.info("Importing \(mdFiles.count) conversation files") var imported = 0 var skipped = 0 var errors = 0 for filename in mdFiles { let filepath = conversationsDir + "/" + filename do { // Read markdown file let markdown = try String(contentsOfFile: filepath, encoding: .utf8) // Parse markdown to ConversationExport let export = try ConversationExport.fromMarkdown(markdown) // Check if conversation already exists (by ID) if let existingId = UUID(uuidString: export.id) { 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 } } // Convert MessageExport to Message let messages = export.messages.map { msgExport -> Message in let role: MessageRole switch msgExport.role.lowercased() { case "user": role = .user case "assistant": role = .assistant case "system": role = .system default: role = .user } return Message( role: role, content: msgExport.content, tokens: msgExport.tokens, cost: msgExport.cost, timestamp: msgExport.timestamp, modelId: msgExport.modelId ) } // 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, folderId: folderId ) log.info("Imported: \(export.name)") imported += 1 } catch { log.error("Failed to import \(filename): \(error.localizedDescription)") errors += 1 } } // Import per-conversation notes (see ConversationNotesService) — runs after the // conversations loop above so a conversation created in this same import pass already // exists locally by the time we try to match notes.json against it. Missing/unparsable // notes.json (older sync repos, or a fresh clone before the first export) is treated as // "no notes to import," not an error. let notesManifestPath = localPath + "/notes.json" if let notesManifestData = try? Data(contentsOf: URL(fileURLWithPath: notesManifestPath)), let notesManifest = try? JSONDecoder().decode(NotesSyncManifest.self, from: notesManifestData) { var notesImported = 0 for (conversationIdString, entry) in notesManifest.notes { guard let conversationId = UUID(uuidString: conversationIdString), let (existingConversation, _) = try? db.loadConversation(id: conversationId) else { continue } // Never overwrite notes the user has already touched locally — same // never-clobber-existing-content philosophy as skipping already-imported // conversation content, and the folder-assignment backfill-only-if-unset above. guard existingConversation.notesFilename == nil, !existingConversation.notesEnabled else { continue } guard let content = try? String(contentsOfFile: localPath + "/notes/" + entry.filename, encoding: .utf8) else { continue } ConversationNotesService.shared.writeRaw(content: content, filename: entry.filename) try? db.setNotesFilename(id: conversationId, filename: entry.filename) try? db.setNotesEnabled(id: conversationId, enabled: entry.enabled) notesImported += 1 } if notesImported > 0 { log.info("Imported notes for \(notesImported) conversations") } } log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors") return (imported, skipped, errors) } /// Create README.md in sync repository private func createReadmeIfNeeded() throws { let localPath = expandPath(settings.syncLocalPath) let readmePath = localPath + "/README.md" // Only create if doesn't exist guard !FileManager.default.fileExists(atPath: readmePath) else { return } let readme = """ # Confab Conversation Sync This repository contains your Confab conversations in markdown format. ## ⚠️ WARNING - DO NOT MANUALLY EDIT **This repository is automatically managed by Confab.** - ❌ **DO NOT manually edit** these files - ❌ **DO NOT add** files to this repository - ❌ **DO NOT delete** files from this repository - ❌ **DO NOT merge conflicts** manually (let Confab handle it) **Why?** Confab rebuilds its internal database from these files. Manual edits will be: - Overwritten on next sync - May cause data corruption - May prevent proper import/restore ## How It Works ### 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` - Per-conversation notes (if you've turned on `/notes on` for a conversation) are exported to `notes/*.md`, tracked in `notes.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, folders.json, and notes.json into its database - Your conversation history, folder structure, and conversation notes 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 - Conversation notes sync the same way; a conversation's notes are only adopted the first time it's imported onto a new machine — if that machine already has its own notes for the same conversation, they're left alone rather than overwritten ## File Structure ``` / ├── README.md # This file ├── folders.json # Your folder structure (auto-managed, don't edit) ├── notes.json # Per-conversation notes index (auto-managed, don't edit) ├── notes/ # Per-conversation notes files │ └── ... └── conversations/ # Your conversations ├── conversation-1.md ├── conversation-2.md └── ... ``` ## Conversation File Format Each `.md` file contains: - Conversation metadata (ID, name, dates) - All messages (user and assistant) - Token counts and costs - Timestamps Example: ```markdown # Python async patterns guide **ID**: `abc-123-def` **Created**: 2026-02-14T10:30:00Z **Updated**: 2026-02-14T11:45:00Z --- ## User How do I use async/await in Python? --- ## Assistant [Response here...] ``` ## Security Notes - This repository contains **plain text** conversations - API keys and secrets are **automatically scanned and blocked** - Keep this repository **private** if conversations contain sensitive info - Use **.gitignore** if you want to exclude specific conversations ## Troubleshooting **Problem:** Files not syncing? - Check Settings → Sync in Confab - Verify git credentials are correct - Check network connection **Problem:** Conflicts after editing? - Restore from git: `git reset --hard origin/main` - Re-export from Confab: Manual Sync → Export All → Push **Problem:** Lost conversations? - Conversations are in your local Confab database - Export manually: Settings → Sync → Export All - Check git history for deleted files ## Support For help with Confab, see: - Settings → Help in Confab app - GitHub issues (if open source) --- **Generated by Confab v1.0** **Last updated:** \(ISO8601DateFormatter().string(from: Date())) """ try readme.write(toFile: readmePath, atomically: true, encoding: .utf8) log.info("Created README.md in sync repository") } // MARK: - Auto-Sync /// Sync on app startup (pull + import only, no push) /// Runs silently in background to fetch changes from other devices func syncOnStartup() async { // First, update status to check if repo is actually cloned await updateStatus() // Only run if configured and cloned guard settings.syncConfigured else { log.debug("Skipping startup sync (sync not configured)") return } guard syncStatus.isCloned else { log.debug("Skipping startup sync (repository not cloned)") return } // Guard against racing autoSync()/syncNow() on the same working tree — this method and // autoSync() are both fired from independent, uncoordinated Tasks (this one at app launch, // autoSync() debounced off chat activity), so without this a pull here could run while // autoSync() is mid-export, leaving a freshly-written untracked file (folders.json, // notes.json) that the pull then refuses to merge over: "untracked working tree files // would be overwritten by merge." Skipping outright (not waiting) is fine here since // startup sync is a one-time best-effort fetch, not something the user is blocked on. guard !isSyncing else { log.debug("Skipping startup sync (another sync already in progress)") return } isSyncing = true defer { isSyncing = false } log.info("Running startup sync (pull + import)...") do { // Pull latest changes try await pull() // Import any new/updated conversations let result = try await importAllConversations() if result.imported > 0 { log.info("Startup sync: imported \(result.imported) conversations") } else { log.debug("Startup sync: no new conversations to import") } } catch { // Don't block app startup on sync errors log.warning("Startup sync failed (non-fatal): \(error.localizedDescription)") } } /// Fire-and-forget sync trigger for conversation-deletion call sites. No-ops when sync /// isn't configured. Deletions otherwise only reach the sync repo on the next incidental /// auto-sync (or never, if the app is closed first) — this makes the removal propagate /// promptly instead of the deleted conversation silently reappearing on next pull+import. func syncAfterDeletion() { guard settings.syncConfigured else { return } Task { await autoSync() } } /// Perform auto-sync with debouncing (export + push) /// Debounces multiple rapid sync requests to avoid spamming git func autoSync() async { // Cancel any pending sync pendingSyncTask?.cancel() // Schedule new sync with 5 second delay pendingSyncTask = Task { // Tracks whether *this* task is the one holding isSyncing, so the catch block below // only ever releases a lock it actually acquired — without this, a cancellation while // still waiting in the loop below (i.e. before this task owns the lock at all) would // incorrectly clear isSyncing out from under whichever other sync is still running. var acquiredLock = false do { // Wait for debounce period try await Task.sleep(for: .seconds(5)) // Check if cancelled during sleep guard !Task.isCancelled else { return } // Wait for any other sync (startup pull, manual Sync Now) already in flight to // finish rather than racing it on the same working tree — see syncOnStartup()'s // guard for what goes wrong otherwise. Waiting (not skipping) here, since // auto-sync is how local changes actually reach the remote; silently dropping this // round could leave a push pending indefinitely if nothing else triggers autoSync // again soon. while await MainActor.run(body: { isSyncing }) { guard !Task.isCancelled else { return } try await Task.sleep(for: .milliseconds(500)) } // Set syncing state await MainActor.run { isSyncing = true lastSyncError = nil } acquiredLock = true log.info("Auto-sync starting (export + push)...") // Export conversations try await exportAllConversations() // Push to git try await push(message: "Auto-sync from Confab") // Success await MainActor.run { isSyncing = false syncStatus.lastSyncTime = Date() } acquiredLock = false log.info("Auto-sync completed successfully") } catch { // Error — only release the lock if this task actually acquired it if acquiredLock { await MainActor.run { isSyncing = false lastSyncError = error.localizedDescription } } log.error("Auto-sync failed: \(error.localizedDescription)") } } // Wait for the task to complete await pendingSyncTask?.value } /// Manual full sync (the Settings → Sync "Sync Now" button): pull → import → export → push, /// in that order so the working tree is fully merged before Confab writes its own files back /// out (see exportAllConversations's ordering note). Throws `.syncInProgress` rather than /// racing autoSync()/syncOnStartup() if either is already running on the same working tree — /// same class of bug as the "untracked working tree files" failure those two guard against. func syncNow() async throws -> (imported: Int, skipped: Int) { guard !isSyncing else { throw SyncError.syncInProgress } isSyncing = true defer { isSyncing = false } try await pull() let result = try await importAllConversations() try await exportAllConversations() try await push() await updateStatus() return (result.imported, result.skipped) } // MARK: - Secret Scanning /// Scan for API keys and secrets in conversations func scanForSecrets(in directory: String) throws { let conversationsDir = directory + "/conversations" guard FileManager.default.fileExists(atPath: conversationsDir) else { return } let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir) let mdFiles = files.filter { $0.hasSuffix(".md") } var detectedSecrets: [String] = [] for filename in mdFiles { let filepath = conversationsDir + "/" + filename let content = try String(contentsOfFile: filepath, encoding: .utf8) let secrets = detectSecretsInText(content) if !secrets.isEmpty { detectedSecrets.append("\(filename): \(secrets.joined(separator: ", "))") } } if !detectedSecrets.isEmpty { log.error("Secrets detected in conversations!") throw SyncError.secretsDetected(detectedSecrets) } } func detectSecretsInText(_ text: String) -> [String] { let patterns: [(name: String, pattern: String)] = [ ("OpenAI Key", "sk-[a-zA-Z0-9]{32,}"), ("Anthropic Key", "sk-ant-[a-zA-Z0-9_-]+"), ("Bearer Token", "Bearer [a-zA-Z0-9_-]{20,}"), ("API Key", "api[_-]?key[\"']?\\s*[:=]\\s*[\"']?[a-zA-Z0-9]{20,}"), ("Access Token", "ghp_[a-zA-Z0-9]{36}"), // GitHub personal access token ] var found: [String] = [] for (name, pattern) in patterns { if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { let range = NSRange(text.startIndex..., in: text) let matches = regex.matches(in: text, range: range) if !matches.isEmpty { found.append(name) } } } return Array(Set(found)) // Remove duplicates } // MARK: - Status Management func updateStatus() async { let localPath = expandPath(settings.syncLocalPath) // Check if cloned syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git") guard syncStatus.isCloned else { return } do { // Get current branch let branch = try await runGit(["branch", "--show-current"], cwd: localPath) syncStatus.currentBranch = branch.trimmingCharacters(in: .whitespacesAndNewlines) // Get uncommitted changes count let status = try await runGit(["status", "--porcelain"], cwd: localPath) let lines = status.components(separatedBy: .newlines).filter { !$0.isEmpty } syncStatus.uncommittedChanges = lines.count // Get remote status _ = try await runGit(["fetch"], cwd: localPath) let remoteDiff = try await runGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd: localPath) let parts = remoteDiff.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t") if parts.count == 2 { let ahead = Int(parts[0]) ?? 0 let behind = Int(parts[1]) ?? 0 if ahead == 0 && behind == 0 { syncStatus.remoteStatus = "up-to-date" } else if ahead > 0 && behind == 0 { syncStatus.remoteStatus = "ahead \(ahead)" } else if ahead == 0 && behind > 0 { syncStatus.remoteStatus = "behind \(behind)" } else { syncStatus.remoteStatus = "diverged" } } } catch { log.error("Failed to update status: \(error.localizedDescription)") } } // MARK: - Helper Methods private func buildAuthenticatedURL() throws -> String { guard settings.syncConfigured else { throw SyncError.notConfigured } let baseURL = settings.syncRepoURL switch settings.syncAuthMethod { case "ssh": // Convert HTTPS URL to SSH format if needed return convertToSSH(baseURL) case "password": guard let username = settings.syncUsername, let password = settings.syncPassword else { throw SyncError.missingCredentials } return injectCredentials(baseURL, username: username, password: password) case "token": guard let token = settings.syncAccessToken else { throw SyncError.missingCredentials } // Use oauth2 as username for tokens return injectCredentials(baseURL, username: "oauth2", password: token) default: return baseURL } } func convertToSSH(_ url: String) -> String { // If already SSH format, return as-is if url.hasPrefix("git@") { return url } // Convert HTTPS to SSH format // https://gitlab.pm/rune/oAI-Sync.git -> git@gitlab.pm:rune/oAI-Sync.git if url.hasPrefix("https://") { let withoutScheme = url.replacingOccurrences(of: "https://", with: "") // Replace first "/" with ":" if let firstSlash = withoutScheme.firstIndex(of: "/") { var sshURL = withoutScheme sshURL.replaceSubrange(firstSlash...firstSlash, with: ":") return "git@" + sshURL } } // If http:// (rare but possible) if url.hasPrefix("http://") { let withoutScheme = url.replacingOccurrences(of: "http://", with: "") if let firstSlash = withoutScheme.firstIndex(of: "/") { var sshURL = withoutScheme sshURL.replaceSubrange(firstSlash...firstSlash, with: ":") return "git@" + sshURL } } // Unknown format, return as-is return url } func injectCredentials(_ url: String, username: String, password: String) -> String { // Convert https://github.com/user/repo.git // To: https://username:password@github.com/user/repo.git if url.hasPrefix("https://") { let withoutScheme = url.replacingOccurrences(of: "https://", with: "") return "https://\(username):\(password)@\(withoutScheme)" } return url // SSH or other protocol } private func runGit(_ args: [String], cwd: String? = nil) async throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = args if let cwd = cwd { process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd)) } let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe process.standardError = errorPipe try process.run() process.waitUntilExit() let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: outputData, encoding: .utf8) ?? "" let error = String(data: errorData, encoding: .utf8) ?? "" guard process.terminationStatus == 0 else { log.error("Git command failed: \(args.joined(separator: " "))") log.error("Error: \(error)") throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error) } return output } private func expandPath(_ path: String) -> String { return NSString(string: path).expandingTildeInPath } private func ensureCloned() throws { let localPath = expandPath(settings.syncLocalPath) guard FileManager.default.fileExists(atPath: localPath + "/.git") else { throw SyncError.repoNotCloned } } func sanitizeFilename(_ name: String) -> String { name.sanitizedForFilename() } static func extractProvider(from url: String) -> String { if url.contains("github.com") { return "GitHub" } else if url.contains("gitlab.com") { return "GitLab" } else if url.contains("gitea") { return "Gitea" } else { return "Git repository" } } }