179 lines
6.1 KiB
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
|
|
}
|
|
}
|
|
}
|