Files
oai-swift/oAI/Services/GitSyncService.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

719 lines
24 KiB
Swift

import Foundation
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI 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 oAI 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://oai.pm>.
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<Void, Never>?
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 \(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 (try? db.loadConversation(id: existingId)) != nil {
// 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
/// 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
}
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)")
}
}
/// 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"
}
}
}