Sapling/Sources/SaplingGit/MacGitProvider.swift

179 lines
6.1 KiB
Swift

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
}
}
}