A macOS utility to track home-manager JJ repo status
1import Foundation
2
3/// Protocol for running jj commands. Abstracted for testability.
4public protocol JujutsuCommandRunner {
5 /// Run a jj command with the given arguments against a repository.
6 ///
7 /// - Parameters:
8 /// - args: Arguments to pass to jj (e.g., ["log", "-r", "trunk()"])
9 /// - repoPath: Path to the repository
10 /// - Returns: The stdout output of the command
11 /// - Throws: If the command fails or jj is not found
12 func run(args: [String], repoPath: String) async throws -> String
13}
14
15/// Real implementation that executes jj via Process.
16public struct LiveJujutsuCommandRunner: JujutsuCommandRunner {
17 /// Additional paths to add to the PATH environment variable.
18 /// Needed because macOS GUI apps don't inherit the user's shell PATH.
19 public let additionalPaths: [String]
20
21 public init(additionalPaths: [String] = []) {
22 self.additionalPaths = additionalPaths
23 }
24
25 /// Discover a reasonable set of Nix-aware PATH entries for the current user.
26 public static func nixAwarePaths() -> [String] {
27 let home = FileManager.default.homeDirectoryForCurrentUser.path
28 return [
29 "\(home)/.nix-profile/bin",
30 "/nix/var/nix/profiles/default/bin",
31 "/run/current-system/sw/bin",
32 "/usr/local/bin",
33 "/usr/bin",
34 "/bin",
35 ]
36 }
37
38 /// Search PATH directories for an executable named `name`.
39 static func findExecutable(named name: String, in paths: [String]) -> String? {
40 let fm = FileManager.default
41 for dir in paths {
42 let candidate = "\(dir)/\(name)"
43 if fm.isExecutableFile(atPath: candidate) {
44 return candidate
45 }
46 }
47 return nil
48 }
49
50 public func run(args: [String], repoPath: String) async throws -> String {
51 try await withCheckedThrowingContinuation { continuation in
52 let process = Process()
53
54 // Build the full PATH (extra paths + existing PATH)
55 var env = ProcessInfo.processInfo.environment
56 let existingPath = env["PATH"] ?? "/usr/bin:/bin"
57 let extraPaths =
58 additionalPaths.isEmpty
59 ? Self.nixAwarePaths()
60 : additionalPaths
61 let fullPath = (extraPaths + [existingPath]).joined(separator: ":")
62 let searchDirs = fullPath.components(separatedBy: ":")
63
64 // Find jj on PATH directly instead of relying on /usr/bin/env
65 // (which doesn't exist in nix sandboxes on Linux).
66 guard let jjPath = Self.findExecutable(named: "jj", in: searchDirs) else {
67 continuation.resume(
68 throwing: CommandError.launchFailed("jj not found in PATH: \(fullPath)"))
69 return
70 }
71
72 process.executableURL = URL(fileURLWithPath: jjPath)
73 process.arguments = args
74
75 env["PATH"] = fullPath
76 process.environment = env
77
78 process.currentDirectoryURL = URL(fileURLWithPath: repoPath)
79
80 let stdout = Pipe()
81 let stderr = Pipe()
82 process.standardOutput = stdout
83 process.standardError = stderr
84
85 do {
86 try process.run()
87 } catch {
88 continuation.resume(throwing: CommandError.launchFailed(error.localizedDescription))
89 return
90 }
91
92 process.waitUntilExit()
93
94 let outData = stdout.fileHandleForReading.readDataToEndOfFile()
95 let errData = stderr.fileHandleForReading.readDataToEndOfFile()
96 let outString = String(data: outData, encoding: .utf8) ?? ""
97 let errString = String(data: errData, encoding: .utf8) ?? ""
98
99 if process.terminationStatus != 0 {
100 continuation.resume(
101 throwing: CommandError.nonZeroExit(
102 status: process.terminationStatus,
103 stderr: errString.trimmingCharacters(in: .whitespacesAndNewlines)
104 ))
105 } else {
106 continuation.resume(returning: outString)
107 }
108 }
109 }
110
111 public enum CommandError: LocalizedError {
112 case launchFailed(String)
113 case nonZeroExit(status: Int32, stderr: String)
114
115 public var errorDescription: String? {
116 switch self {
117 case .launchFailed(let reason):
118 return "Failed to launch jj: \(reason)"
119 case .nonZeroExit(let status, let stderr):
120 return "jj exited with status \(status): \(stderr)"
121 }
122 }
123 }
124}