Files
oai-swift/oAI/Services/GitSyncService.swift
T
rune 87eab6fd75 Run git subprocess off the main thread with a timeout
runGit() previously called Process.waitUntilExit() synchronously on
the MainActor with no timeout. If a git network operation (push/pull/
fetch) was in flight when the Mac went to sleep, the dead connection
could hang indefinitely on wake with no OS-level timeout of its own,
freezing the entire app UI with nothing logged since the command never
actually finished. Now runs on a background queue with a 30s timeout
(120s for clone), matching the pattern already used by bash_execute.
2026-08-04 12:59:09 +02:00

1165 lines
51 KiB
Swift

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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
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<Void, Never>?
/// 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], timeout: 120)
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/<filename> 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<String>, 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<String>,
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<String>,
localFolderIds: Set<String>
) -> [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<file>\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
}
/// Runs off the main thread with a hard timeout, rather than blocking synchronously on
/// `waitUntilExit()` on the (MainActor-isolated, per this project's default actor isolation)
/// calling thread. A plain `git fetch`/`pull`/`push` over a connection that died silently
/// during system sleep can otherwise hang indefinitely with no OS-level timeout of its own —
/// since that used to block the main thread, it froze the entire app UI with nothing to show
/// for it in the logs (no error is ever produced by a command that never finishes). Every
/// MainActor-touching value (paths, the logger) is captured *before* dispatching to the
/// background queue — the queue's closure must not touch `self` or other MainActor state.
private func runGit(_ args: [String], cwd: String? = nil, timeout: TimeInterval = 30) async throws -> String {
let workingDirectoryURL = cwd.map { URL(fileURLWithPath: expandPath($0)) }
let log = self.log
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = args
if let workingDirectoryURL {
process.currentDirectoryURL = workingDirectoryURL
}
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
var timedOut = false
let timeoutItem = DispatchWorkItem {
if process.isRunning {
timedOut = true
process.terminate()
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutItem)
do {
try process.run()
process.waitUntilExit()
} catch {
timeoutItem.cancel()
continuation.resume(throwing: error)
return
}
timeoutItem.cancel()
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 {
let message = timedOut
? "Timed out after \(Int(timeout))s — this can happen if your Mac just woke from sleep and the network hasn't reconnected yet. Try again in a moment."
: (error.isEmpty ? "Unknown error" : error)
log.error("Git command failed: \(args.joined(separator: " "))")
log.error("Error: \(message)")
continuation.resume(throwing: SyncError.gitFailed(message))
return
}
continuation.resume(returning: 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"
}
}
}