Files
oai-swift/oAI/Services/GitSyncService.swift

688 lines
23 KiB
Swift

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<Void, Never>?
// 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
/// Sync on app startup (pull + import only, no push)
/// Runs silently in background to fetch changes from other devices
func syncOnStartup() async {
// Only run if configured and cloned
guard settings.syncConfigured && syncStatus.isCloned else {
log.debug("Skipping startup sync (not configured or 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"
}
}
}