688 lines
23 KiB
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"
|
|
}
|
|
}
|
|
}
|