A macOS utility to track home-manager JJ repo status
at main 124 lines 4.1 kB view raw
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}