import Foundation import SaplingCore public final class MacGitProvider: GitProvider, @unchecked Sendable { public let displayName = "System Git" public init() {} public func initRepository(at path: URL) async throws -> GitRepository { #if os(macOS) try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) try runGit(["init"], in: path) return GitRepository(name: path.lastPathComponent, rootURL: path) #else throw GitProviderError.unsupportedOnCurrentPlatform #endif } public func clone(remoteURL: URL, to destinationURL: URL) async throws -> GitRepository { #if os(macOS) try runGit(["clone", remoteURL.absoluteString, destinationURL.path], in: destinationURL.deletingLastPathComponent()) return GitRepository(name: destinationURL.lastPathComponent, rootURL: destinationURL) #else throw GitProviderError.unsupportedOnCurrentPlatform #endif } public func repository(at path: URL) -> any Repository { MacGitRepository(rootURL: path) { [self] arguments, directory in try runGit(arguments, in: directory) } } #if os(macOS) @discardableResult private func runGit(_ arguments: [String], in directory: URL) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = ["git"] + arguments process.currentDirectoryURL = directory let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr try process.run() process.waitUntilExit() let outputData = stdout.fileHandleForReading.readDataToEndOfFile() let errorData = stderr.fileHandleForReading.readDataToEndOfFile() let output = String(data: outputData + errorData, encoding: .utf8) ?? "" guard process.terminationStatus == 0 else { throw GitProviderError.commandFailed( command: "git \(arguments.joined(separator: " "))", exitCode: process.terminationStatus, output: output ) } return output } #else private func runGit(_ arguments: [String], in directory: URL) throws -> String { throw GitProviderError.unsupportedOnCurrentPlatform } #endif } private struct MacGitRepository: Repository { let rootURL: URL let runner: @Sendable (_ arguments: [String], _ directory: URL) throws -> String func status() async throws -> [GitFileStatus] { let output = try runner(["status", "--porcelain"], rootURL) return output .split(separator: "\n") .compactMap(parseStatusLine) } func add(paths: [String]) async throws { _ = try runner(["add"] + paths, rootURL) } func commit(message: String) async throws -> GitCommit { _ = try runner(["commit", "-m", message], rootURL) let hash = try runner(["rev-parse", "HEAD"], rootURL).trimmingCharacters(in: .whitespacesAndNewlines) return GitCommit( id: hash, shortHash: String(hash.prefix(7)), authorName: "", authorEmail: "", message: message, authoredAt: Date() ) } func push(remote: String?, branch: String?) async throws { _ = try runner(compactArguments(["push", remote, branch]), rootURL) } func pull(remote: String?, branch: String?) async throws { _ = try runner(compactArguments(["pull", remote, branch]), rootURL) } func createBranch(named name: String) async throws -> GitBranch { _ = try runner(["branch", name], rootURL) return GitBranch(name: name) } func switchBranch(named name: String) async throws -> GitBranch { _ = try runner(["switch", name], rootURL) return GitBranch(name: name, isCurrent: true) } func merge(branch name: String) async throws { _ = try runner(["merge", name], rootURL) } func addSubmodule(remoteURL: URL, path: String) async throws -> Subproject { _ = try runner(["submodule", "add", remoteURL.absoluteString, path], rootURL) return Subproject( name: URL(fileURLWithPath: path).lastPathComponent, path: path, repositoryURL: rootURL.appendingPathComponent(path), remoteURL: remoteURL ) } func removeSubmodule(path: String) async throws { // TODO: Coordinate .gitmodules edits, deinit, and metadata cleanup in a transaction. _ = try runner(["submodule", "deinit", "-f", path], rootURL) _ = try runner(["rm", "-f", path], rootURL) } func updateSubmodules(initSubmodules: Bool, recursive: Bool) async throws { var arguments = ["submodule", "update"] if initSubmodules { arguments.append("--init") } if recursive { arguments.append("--recursive") } _ = try runner(arguments, rootURL) } func syncSubmodules(recursive: Bool) async throws { var arguments = ["submodule", "sync"] if recursive { arguments.append("--recursive") } _ = try runner(arguments, rootURL) } private func parseStatusLine(_ line: Substring) -> GitFileStatus? { guard line.count >= 4 else { return nil } let code = String(line.prefix(2)) let path = String(line.dropFirst(3)) let state: GitFileState if code.contains("U") { state = .conflicted } else if code.contains("A") { state = .added } else if code.contains("D") { state = .deleted } else if code.contains("R") { state = .renamed } else if code == "??" { state = .untracked } else { state = .modified } return GitFileStatus(path: path, state: state) } private func compactArguments(_ values: [String?]) -> [String] { values.compactMap { value in guard let value, !value.isEmpty else { return nil } return value } } }