Files
oai-swift/oAI/Services/GitSyncService.swift
T
rune 6480a50eee Sync folder structure via Git Sync (folders.json), plus bugs found testing it
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
  last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
  conversationId→folderId assignments, imported before conversation
  files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
  guarded the same way conversation-orphan cleanup already is against
  an empty/stale manifest wiping everything.

Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
  directly into the database — only reloaded on launch or when the
  advanced conversation list closed, with no equivalent hook for the
  Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
  as an untracked file that then collided with the remote's tracked
  copy on the next pull ("untracked working tree files would be
  overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
  import, so any conversation already synced to a machine before this
  feature existed never got filed — which in practice is every
  conversation on a second machine, not an edge case. Now backfills
  a folder assignment for existing conversations that aren't filed
  anywhere locally yet, without clobbering an already-set folderId.

Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
2026-08-03 11:48:23 +02:00

872 lines
34 KiB
Swift

import Foundation
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab 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 Confab 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://confab.no>.
import os
@Observable
class GitSyncService {
static let shared = GitSyncService()
private let settings = SettingsService.shared
private let db = DatabaseService.shared
private let log = Logger(subsystem: Log.subsystem, 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
// Import immediately so this machine's DB is never left empty after a clone —
// an empty DB is what makes the next export think every existing conversation
// was deleted (see exportAllConversations's orphan-cleanup guard).
_ = try? await importAllConversations()
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 Confab") 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)")
}
// Remove files for conversations that no longer exist locally (e.g. deleted since
// the last export). Without this, a deletion is never reflected in the sync repo,
// so importAllConversations() silently resurrects it on every future pull.
let currentIds = Set(conversations.map { $0.id.uuidString })
let existingFiles = (try? FileManager.default.contentsOfDirectory(atPath: conversationsDir)) ?? []
let mdFilesWithContent: [(filename: String, markdown: String)] = existingFiles
.filter { $0.hasSuffix(".md") }
.compactMap { filename in
guard let markdown = try? String(contentsOfFile: conversationsDir + "/" + filename, encoding: .utf8)
else { return nil }
return (filename, markdown)
}
for filename in Self.orphanedExportFilenames(currentIds: currentIds, files: mdFilesWithContent) {
try? FileManager.default.removeItem(atPath: conversationsDir + "/" + filename)
log.info("Removed orphaned export for deleted conversation: \(filename)")
}
// Export the folder tree + conversation→folder assignments alongside the conversations
// themselves, so a fresh machine's import can restore folder structure too. See
// upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this.
let allFolders = try db.listFolders()
let manifest = FolderSyncManifest(
folders: allFolders.map {
FolderSyncManifest.FolderEntry(
id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString,
createdAt: $0.createdAt, updatedAt: $0.updatedAt
)
},
assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in
conv.folderId.map { (conv.id.uuidString, $0.uuidString) }
})
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let manifestData = try encoder.encode(manifest)
try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json"))
log.debug("Exported folders.json (\(allFolders.count) folders)")
await updateStatus()
}
/// Given the current conversation IDs and the (filename, markdown content) pairs found in
/// the sync repo's conversations directory, returns the filenames whose export ID doesn't
/// match any current conversation — i.e. files safe to delete because their conversation
/// was removed from the database since the last export.
nonisolated static func orphanedExportFilenames(
currentIds: Set<String>,
files: [(filename: String, markdown: String)]
) -> [String] {
// A locally-empty conversation list is indistinguishable here from "nothing has been
// imported into this machine's DB yet" (e.g. right after a fresh clone). Treating it as
// "every existing file was deleted" wiped a user's entire sync repo in production: clone
// completed, an auto-sync fired before the post-clone import finished, every synced
// conversation looked orphaned, and the deletion got committed and pushed. Skipping
// cleanup here means a genuine last-conversation deletion won't propagate until another
// conversation exists locally — a far smaller cost than mass data loss.
guard !currentIds.isEmpty else { return [] }
return files.compactMap { file in
guard let export = try? ConversationExport.fromMarkdown(file.markdown) else { return nil }
return currentIds.contains(export.id) ? nil : file.filename
}
}
/// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids
/// that exist locally, returns the local ids that should be deleted (folder was removed
/// upstream since the last sync). Same empty-manifest safety guard as
/// `orphanedExportFilenames` — an empty manifest is indistinguishable from "haven't imported
/// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before
/// the first export), so treating it as "delete every local folder" would be exactly the same
/// class of mass-deletion bug that hit conversation sync.
nonisolated static func orphanedLocalFolderIds(
manifestFolderIds: Set<String>,
localFolderIds: Set<String>
) -> [String] {
guard !manifestFolderIds.isEmpty else { return [] }
return localFolderIds.filter { !manifestFolderIds.contains($0) }
}
/// 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)
}
// Import the folder tree + assignments before any conversation, so a brand-new
// conversation created below can immediately reference a folder that already exists
// locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the
// first export) is treated as "no folders to import," not an error.
var folderAssignments: [String: String] = [:]
let manifestPath = localPath + "/folders.json"
if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) {
folderAssignments = manifest.assignments
// Insert parents before children so parentId's foreign key reference (folders.
// parentId references folders(id)) is always satisfied.
let manifestFolders = manifest.folders.compactMap { entry -> Folder? in
guard let id = UUID(uuidString: entry.id) else { return nil }
return Folder(
id: id, name: entry.name, createdAt: entry.createdAt,
parentId: entry.parentId.flatMap { UUID(uuidString: $0) },
updatedAt: entry.updatedAt
)
}
for (folder, _) in Folder.orderedTree(from: manifestFolders) {
try? db.upsertSyncedFolder(
id: folder.id, name: folder.name, parentId: folder.parentId,
createdAt: folder.createdAt, updatedAt: folder.updatedAt
)
}
// Delete local folders no longer present upstream — reparents their contents up
// one level via the existing deleteFolder semantics.
let manifestFolderIds = Set(manifest.folders.map { $0.id })
let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? [])
for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) {
if let id = UUID(uuidString: idString) {
try? db.deleteFolder(id: id)
log.info("Removed local folder no longer present in sync repo: \(idString)")
}
}
log.debug("Imported folders.json (\(manifest.folders.count) folders)")
}
}
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 (existingConversation, _) = try? db.loadConversation(id: existingId) {
// Already exists - skip re-importing its content, but still backfill a
// folder assignment if the manifest has one and this conversation isn't
// filed anywhere locally yet. Without this, a conversation that was synced
// to this machine before folder sync existed (or before it was ever put in
// a folder on any machine) would never get filed here — every conversation
// in a multi-machine setup already exists locally by the time folders.json
// starts carrying assignments, so this isn't an edge case, it's the normal
// case. Never overwrites an existing local folderId, so a conversation
// already filed (by this machine or a prior import) isn't silently moved.
if existingConversation.folderId == nil,
let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) {
try? db.moveConversation(id: existingId, toFolder: assignedFolderId)
}
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, plus its folder assignment (if any) from
// folders.json — only applies here at first-import; an existing local conversation
// that's later moved to a different folder on another machine doesn't get updated,
// matching how its content/name aren't updated either once already imported.
let conversationId = UUID(uuidString: export.id) ?? UUID()
let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) }
_ = try db.saveConversation(
id: conversationId,
name: export.name,
messages: messages,
primaryModel: export.primaryModel,
folderId: folderId
)
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 = """
# Confab Conversation Sync
This repository contains your Confab conversations in markdown format.
## ⚠️ WARNING - DO NOT MANUALLY EDIT
**This repository is automatically managed by Confab.**
- ❌ **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 Confab handle it)
**Why?** Confab 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)
- Confab saves conversations to its local database
- Auto-sync exports conversations to `conversations/*.md`
- Your folder structure (if you organize conversations into folders) is exported to `folders.json`
- Files are committed and pushed to this git repository
### Import (On New Machine)
- Clone this repository on a new machine
- Confab imports markdown files and folders.json into its database
- Your conversation history and folder structure are 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
- Folder renames/moves also sync between machines; a conversation's folder is only set
the first time it's imported onto a new machine
## File Structure
```
/
├── README.md # This file
├── folders.json # Your folder structure (auto-managed, don't edit)
└── 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 Confab
- Verify git credentials are correct
- Check network connection
**Problem:** Conflicts after editing?
- Restore from git: `git reset --hard origin/main`
- Re-export from Confab: Manual Sync → Export All → Push
**Problem:** Lost conversations?
- Conversations are in your local Confab database
- Export manually: Settings → Sync → Export All
- Check git history for deleted files
## Support
For help with Confab, see:
- Settings → Help in Confab app
- GitHub issues (if open source)
---
**Generated by Confab 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)")
}
}
/// Fire-and-forget sync trigger for conversation-deletion call sites. No-ops when sync
/// isn't configured. Deletions otherwise only reach the sync repo on the next incidental
/// auto-sync (or never, if the app is closed first) — this makes the removal propagate
/// promptly instead of the deleted conversation silently reappearing on next pull+import.
func syncAfterDeletion() {
guard settings.syncConfigured else { return }
Task { await autoSync() }
}
/// 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 Confab")
// 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"
}
}
}