Files
CxIDE/Services/GitService.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge)
- Agentic AI loop: LLM plans → tool calls → execution → feedback loop
- 15 agent tools: file ops, terminal, git, xcode build, code intel
- 7 persistent terminal tools with background session management
- Chat sidebar with agent step rendering and auto-apply
- NVIDIA NIM API integration (Llama 3.3 70B default)
- OpenAI tool_calls format with prompt-based fallback
- Code editor with syntax highlighting and multi-tab support
- File tree, console view, terminal view
- Git integration and workspace management
2026-04-21 16:05:52 -05:00

255 lines
8.4 KiB
Swift

import Foundation
import SwiftUI
// MARK: - Git Service
@MainActor
final class GitService: ObservableObject {
@Published var status: GitStatusSummary = GitStatusSummary()
@Published var branches: [GitBranch] = []
@Published var isGitRepo: Bool = false
@Published var isRefreshing: Bool = false
private var workingDirectory: URL?
func configure(workingDirectory: URL) {
self.workingDirectory = workingDirectory
Task { await checkIfGitRepo() }
}
// MARK: - Status
func refresh() async {
guard let dir = workingDirectory, isGitRepo else { return }
isRefreshing = true
defer { isRefreshing = false }
async let statusResult = fetchStatus(in: dir)
async let branchResult = fetchBranches(in: dir)
status = await statusResult
branches = await branchResult
}
// MARK: - Operations
func stageFile(_ path: String) async {
guard let dir = workingDirectory else { return }
_ = await runGit(["add", path], in: dir)
await refresh()
}
func unstageFile(_ path: String) async {
guard let dir = workingDirectory else { return }
_ = await runGit(["reset", "HEAD", path], in: dir)
await refresh()
}
func stageAll() async {
guard let dir = workingDirectory else { return }
_ = await runGit(["add", "-A"], in: dir)
await refresh()
}
func commit(message: String) async -> Bool {
guard let dir = workingDirectory, !message.isEmpty else { return false }
let result = await runGit(["commit", "-m", message], in: dir)
await refresh()
return result != nil
}
func clone(url: String, to destination: URL) async -> Bool {
let result = await runGit(["clone", url, destination.path], in: destination.deletingLastPathComponent())
if result != nil {
configure(workingDirectory: destination)
await refresh()
}
return result != nil
}
func discardChanges(_ path: String) async {
guard let dir = workingDirectory else { return }
_ = await runGit(["checkout", "--", path], in: dir)
await refresh()
}
func switchBranch(_ name: String) async -> Bool {
guard let dir = workingDirectory else { return false }
let result = await runGit(["checkout", name], in: dir)
await refresh()
return result != nil
}
func diff(for path: String) async -> String {
guard let dir = workingDirectory else { return "" }
return await runGit(["diff", path], in: dir) ?? ""
}
func log(count: Int = 20) async -> [GitLogEntry] {
guard let dir = workingDirectory else { return [] }
let format = "%H%n%an%n%ae%n%s%n%ci%n---"
guard let output = await runGit(
["log", "--format=\(format)", "-n", "\(count)"],
in: dir
) else { return [] }
var entries: [GitLogEntry] = []
let chunks = output.components(separatedBy: "\n---\n")
for chunk in chunks {
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 4 else { continue }
entries.append(GitLogEntry(
hash: String(lines[0].prefix(8)),
author: lines[1],
email: lines[2],
message: lines[3],
date: lines.count > 4 ? lines[4] : ""
))
}
return entries
}
// MARK: - Internal
private func checkIfGitRepo() async {
guard let dir = workingDirectory else {
isGitRepo = false
return
}
let result = await runGit(["rev-parse", "--git-dir"], in: dir)
isGitRepo = result != nil
if isGitRepo {
await refresh()
}
}
private func fetchStatus(in dir: URL) async -> GitStatusSummary {
var summary = GitStatusSummary()
// Current branch
if let branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], in: dir) {
summary.branch = branch.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Ahead/behind
if let abOutput = await runGit(
["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
in: dir
) {
let parts = abOutput.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\t")
if parts.count == 2 {
summary.ahead = Int(parts[0]) ?? 0
summary.behind = Int(parts[1]) ?? 0
}
}
// File status
if let statusOutput = await runGit(["status", "--porcelain"], in: dir) {
let lines = statusOutput.components(separatedBy: "\n").filter { !$0.isEmpty }
for line in lines {
guard line.count >= 3 else { continue }
let statusCode = String(line.prefix(2))
let filePath = String(line.dropFirst(3))
let gitStatus: GitFileStatus
let isStaged: Bool
switch statusCode.trimmingCharacters(in: .whitespaces) {
case "M":
gitStatus = .modified
isStaged = statusCode.first != " "
case "A":
gitStatus = .added
isStaged = true
case "D":
gitStatus = .deleted
isStaged = statusCode.first != " "
case "R":
gitStatus = .renamed
isStaged = true
case "??":
gitStatus = .untracked
isStaged = false
case "UU", "AA", "DD":
gitStatus = .conflicted
isStaged = false
default:
gitStatus = .modified
isStaged = statusCode.first != " "
}
summary.files.append(GitFileChange(path: filePath, status: gitStatus, staged: isStaged))
switch gitStatus {
case .modified: summary.modified += 1
case .added: if isStaged { summary.staged += 1 }
case .untracked: summary.untracked += 1
case .conflicted: summary.conflicted += 1
default: break
}
}
}
return summary
}
private func fetchBranches(in dir: URL) async -> [GitBranch] {
guard let output = await runGit(["branch", "-a", "--no-color"], in: dir) else { return [] }
return output.components(separatedBy: "\n")
.filter { !$0.isEmpty }
.map { line in
let isCurrent = line.hasPrefix("*")
let name = line.trimmingCharacters(in: .whitespaces)
.replacingOccurrences(of: "* ", with: "")
.trimmingCharacters(in: .whitespaces)
let isRemote = name.hasPrefix("remotes/")
return GitBranch(
name: isRemote ? String(name.dropFirst("remotes/".count)) : name,
isCurrent: isCurrent,
isRemote: isRemote
)
}
}
private func runGit(_ arguments: [String], in directory: URL) async -> String? {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = arguments
process.currentDirectoryURL = directory
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
continuation.resume(returning: String(data: data, encoding: .utf8))
} else {
continuation.resume(returning: nil)
}
} catch {
continuation.resume(returning: nil)
}
}
}
}
}
// MARK: - Git Log
struct GitLogEntry: Identifiable {
let id = UUID()
let hash: String
let author: String
let email: String
let message: String
let date: String
}