PadXcode-iPad/Git/GitService.swift
2026-04-12 22:07:35 -07:00

127 lines
5.3 KiB
Swift

import UIKit
/// Builds and launches Working Copy x-callback-url commands.
/// Works with any remote GitHub, local bare repo over SSH via Tailscale,
/// Gitea, etc. The remote URL is managed entirely inside Working Copy;
/// PadXcode only needs the repo display name.
final class GitService {
// Read from UserDefaults at call time so changes saved in Settings
// are reflected immediately without requiring an app restart.
private var apiKey: String {
UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
}
private let callbackBase = "padxcode://git-callback"
init() {}
// MARK: - Navigation
func listRepositories() { open(build("repos", [:])) }
func fetchStatus(repo: String) {
open(build("status", ["repo": repo, "unchanged": "1"], success: "status"))
}
func fetchLog(repo: String, limit: Int = 30) {
open(build("log", ["repo": repo, "limit": "\(limit)"], success: "log"))
}
func fetchBranches(repo: String) {
open(build("branches", ["repo": repo], success: "branches"))
}
// MARK: - Branch operations
func checkout(repo: String, branch: String) {
open(build("checkout", ["repo": repo, "branch": branch], success: "checkout"))
}
func createBranch(repo: String, branch: String) {
open(build("checkout", ["repo": repo, "branch": branch, "mode": "create"], success: "checkout"))
}
func merge(repo: String, branch: String) {
open(build("merge", ["repo": repo, "branch": branch], success: "merge"))
}
// MARK: - Commit / Push / Pull
func commit(repo: String, message: String, path: String = "", limit: Int = 999) {
var p: [String: String] = ["repo": repo, "message": message, "limit": "\(limit)"]
if !path.isEmpty { p["path"] = path }
open(build("commit", p, success: "commit"))
}
func push(repo: String) { open(build("push", ["repo": repo], success: "push")) }
func pull(repo: String) { open(build("pull", ["repo": repo], success: "pull")) }
func fetch(repo: String) { open(build("fetch", ["repo": repo], success: "fetch")) }
/// Commit then push in one Working Copy session using the `chain` command.
/// This is the primary action used by the Git panel Commit & Push button.
func commitAndPush(repo: String, message: String, path: String = "") {
var components = URLComponents(string: "working-copy://x-callback-url/chain")!
var items: [URLQueryItem] = [
.init(name: "key", value: apiKey),
.init(name: "repo", value: repo),
.init(name: "x-error", value: "\(callbackBase)?action=error"),
.init(name: "command", value: "commit"),
.init(name: "message", value: message),
.init(name: "limit", value: "999"),
]
if !path.isEmpty { items.append(.init(name: "path", value: path)) }
items += [
.init(name: "command", value: "push"),
.init(name: "x-success", value: "\(callbackBase)?action=push"),
]
components.queryItems = items
if let url = components.url { UIApplication.shared.open(url) }
}
// MARK: - File operations
/// Write a file into the Working Copy repo (called by FileSyncPipeline on every save).
/// mode=overwrite: replaces even if the file has uncommitted changes.
/// askCommit=false: silent write, user commits manually from the Git panel.
func writeFile(repo: String, path: String, content: String, askCommit: Bool = false) {
var p: [String: String] = ["repo": repo, "path": path, "text": content, "mode": "overwrite"]
if askCommit { p["askcommit"] = "1" }
open(build("write", p, success: "write"))
}
// MARK: - Open in Working Copy
func openInWorkingCopy(repo: String, path: String, mode: String = "content") {
var c = URLComponents(string: "working-copy://open")!
c.queryItems = [.init(name: "repo", value: repo),
.init(name: "path", value: path),
.init(name: "mode", value: mode)]
if let url = c.url { UIApplication.shared.open(url) }
}
// MARK: - URL Builder
private func build(
_ command: String,
_ params: [String: String],
success action: String? = nil
) -> URL {
var c = URLComponents(string: "working-copy://x-callback-url/\(command)")!
var items: [URLQueryItem] = [
.init(name: "key", value: apiKey),
.init(name: "x-error", value: "\(callbackBase)?action=error"),
]
if let action {
// Append a trailing "&" so Working Copy appends the JSON result as a bare value
items.append(.init(name: "x-success", value: "\(callbackBase)?action=\(action)&"))
}
for (k, v) in params { items.append(.init(name: k, value: v)) }
c.queryItems = items
// URL construction from URLComponents with a valid scheme cannot fail,
// but guard defensively rather than force-unwrap.
guard let url = c.url else {
assertionFailure("Failed to build Working Copy URL for command: \(command)")
return URL(string: "working-copy://")!
}
return url
}
private func open(_ url: URL) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}