import Foundation import os @Observable class GitSyncService { static let shared = GitSyncService() private let settings = SettingsService.shared private let db = DatabaseService.shared private let log = Logger(subsystem: "com.oai.oAI", category: "sync") private(set) var syncStatus = SyncStatus() private(set) var isSyncing = false private(set) var lastSyncError: String? // Debounce tracking private var pendingSyncTask: Task? // 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 \(extractProvider())" } /// 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 await updateStatus() } /// Pull latest changes from remote func pull() async throws { try ensureCloned() let localPath = expandPath(settings.syncLocalPath) log.info("Pulling changes from remote") _ = try await runGit(["pull", "--ff-only"], cwd: localPath) syncStatus.lastSyncTime = Date() await updateStatus() } /// Push local changes to remote func push(message: String = "Sync from oAI") 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)") } await updateStatus() } /// 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) } 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 existing = try? db.loadConversation(id: existingId) { // Already exists - skip 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 let conversationId = UUID(uuidString: export.id) ?? UUID() _ = try db.saveConversation( id: conversationId, name: export.name, messages: messages, primaryModel: export.primaryModel ) log.info("Imported: \(export.name)") imported += 1 } catch { log.error("Failed to import \(filename): \(error.localizedDescription)") errors += 1 } } 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 = """ # oAI Conversation Sync This repository contains your oAI conversations in markdown format. ## ⚠️ WARNING - DO NOT MANUALLY EDIT **This repository is automatically managed by oAI.** - ❌ **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 oAI handle it) **Why?** oAI 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) - oAI saves conversations to its local database - Auto-sync exports conversations to `conversations/*.md` - Files are committed and pushed to this git repository ### Import (On New Machine) - Clone this repository on a new machine - oAI imports markdown files into its database - Your conversation history is 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 ## File Structure ``` / ├── README.md # This file └── 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 oAI - Verify git credentials are correct - Check network connection **Problem:** Conflicts after editing? - Restore from git: `git reset --hard origin/main` - Re-export from oAI: Manual Sync → Export All → Push **Problem:** Lost conversations? - Conversations are in your local oAI database - Export manually: Settings → Sync → Export All - Check git history for deleted files ## Support For help with oAI, see: - Settings → Help in oAI app - GitHub issues (if open source) --- **Generated by oAI 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 /// 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 { do { // Wait for debounce period try await Task.sleep(for: .seconds(5)) // Check if cancelled during sleep guard !Task.isCancelled else { return } // Set syncing state await MainActor.run { isSyncing = true lastSyncError = nil } log.info("Auto-sync starting (export + push)...") // Export conversations try await exportAllConversations() // Push to git try await push(message: "Auto-sync from oAI") // Success await MainActor.run { isSyncing = false syncStatus.lastSyncTime = Date() } log.info("Auto-sync completed successfully") } catch { // Error await MainActor.run { isSyncing = false lastSyncError = error.localizedDescription } log.error("Auto-sync failed: \(error.localizedDescription)") } } // Wait for the task to complete await pendingSyncTask?.value } // 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) } } private 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 } } private 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 } private 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 } } private func sanitizeFilename(_ name: String) -> String { // Remove invalid filename characters let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|") return name.components(separatedBy: invalid).joined(separator: "-") } private func extractProvider() -> String { let url = settings.syncRepoURL 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" } } }