The oAITests PBXNativeTarget (PBXContainerItemProxy, PBXTargetDependency,
XCBuildConfiguration with TEST_HOST/BUNDLE_LOADER, the "recommended
settings" changes accepted when the target was created -- DEAD_CODE_STRIPPING,
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED, STRING_CATALOG_GENERATE_SYMBOLS,
DEVELOPMENT_TEAM moved to project-level inheritance) was somehow never
actually staged in the very first "Add real oAITests target" commit
(8c7fb59) despite every xcodebuild test run since then depending on it
being present on disk. Every subsequent commit this session only staged
specific file paths (never oAI.xcodeproj again), so the gap went
unnoticed until a full `git status` review here.
Without this, anyone else pulling the branch (or a truly clean checkout
on this machine) would have all the .swift test files but no target to
compile them into -- xcodebuild test would fail to find oAITests at all.
Confirmed the diff is exactly the expected target-wiring content, nothing
unrelated or corrupted, before committing.
718 lines
24 KiB
Swift
718 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 \(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])
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func sanitizeFilename(_ name: String) -> String {
|
|
// Remove invalid filename characters
|
|
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
|
|
return name.components(separatedBy: invalid).joined(separator: "-")
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|