A macOS utility to track home-manager JJ repo status

Extract HMStatus cross-platform library

Move RepoStatus, StatusParser, and JujutsuCommandRunner into a
separate HMStatus library target with public API. This enables
cross-platform use (Linux + macOS) and prepares for a CLI tool.

AI-assisted: GitLab Duo Agentic Chat (Claude Opus 4.6)

+57 -36
+14 -3
Package.swift
··· 7 7 platforms: [ 8 8 .macOS(.v13) 9 9 ], 10 + products: [ 11 + .library(name: "HMStatus", targets: ["HMStatus"]) 12 + ], 10 13 targets: [ 14 + // Cross-platform library — pure Foundation, no Apple frameworks 15 + .target( 16 + name: "HMStatus", 17 + path: "Sources/HMStatus" 18 + ), 19 + // macOS menu bar app 11 20 .executableTarget( 12 21 name: "HomeManagerStatus", 22 + dependencies: ["HMStatus"], 13 23 path: "Sources/HomeManagerStatus" 14 24 ), 25 + // Cross-platform tests for the library 15 26 .testTarget( 16 - name: "HomeManagerStatusTests", 17 - dependencies: ["HomeManagerStatus"], 18 - path: "Tests/HomeManagerStatusTests" 27 + name: "HMStatusTests", 28 + dependencies: ["HMStatus"], 29 + path: "Tests/HMStatusTests" 19 30 ), 20 31 ] 21 32 )
+1
Sources/HomeManagerStatus/HomeManagerStatusApp.swift
··· 1 1 import AppKit 2 + import HMStatus 2 3 import SwiftUI 3 4 4 5 @main
+8 -8
Sources/HomeManagerStatus/JujutsuCommandRunner.swift Sources/HMStatus/JujutsuCommandRunner.swift
··· 1 1 import Foundation 2 2 3 3 /// Protocol for running jj commands. Abstracted for testability. 4 - protocol JujutsuCommandRunner { 4 + public protocol JujutsuCommandRunner { 5 5 /// Run a jj command with the given arguments against a repository. 6 6 /// 7 7 /// - Parameters: ··· 13 13 } 14 14 15 15 /// Real implementation that executes jj via Process. 16 - struct LiveJujutsuCommandRunner: JujutsuCommandRunner { 16 + public struct LiveJujutsuCommandRunner: JujutsuCommandRunner { 17 17 /// Additional paths to add to the PATH environment variable. 18 18 /// Needed because macOS GUI apps don't inherit the user's shell PATH. 19 - let additionalPaths: [String] 19 + public let additionalPaths: [String] 20 20 21 - init(additionalPaths: [String] = []) { 21 + public init(additionalPaths: [String] = []) { 22 22 self.additionalPaths = additionalPaths 23 23 } 24 24 25 25 /// Discover a reasonable set of Nix-aware PATH entries for the current user. 26 - static func nixAwarePaths() -> [String] { 26 + public static func nixAwarePaths() -> [String] { 27 27 let home = FileManager.default.homeDirectoryForCurrentUser.path 28 28 return [ 29 29 "\(home)/.nix-profile/bin", ··· 35 35 ] 36 36 } 37 37 38 - func run(args: [String], repoPath: String) async throws -> String { 38 + public func run(args: [String], repoPath: String) async throws -> String { 39 39 try await withCheckedThrowingContinuation { continuation in 40 40 let process = Process() 41 41 process.executableURL = URL(fileURLWithPath: "/usr/bin/env") ··· 84 84 } 85 85 } 86 86 87 - enum CommandError: LocalizedError { 87 + public enum CommandError: LocalizedError { 88 88 case launchFailed(String) 89 89 case nonZeroExit(status: Int32, stderr: String) 90 90 91 - var errorDescription: String? { 91 + public var errorDescription: String? { 92 92 switch self { 93 93 case .launchFailed(let reason): 94 94 return "Failed to launch jj: \(reason)"
+24 -16
Sources/HomeManagerStatus/RepoStatus.swift Sources/HMStatus/RepoStatus.swift
··· 1 1 import Foundation 2 2 3 3 /// Represents the current status of the jj repository relative to trunk(). 4 - struct RepoStatus: Equatable { 4 + public struct RepoStatus: Equatable { 5 5 /// Number of commits on the current working copy ancestry that are not on trunk(). 6 - let ahead: Int 6 + public let ahead: Int 7 7 /// Number of commits on trunk() that are not on the current working copy ancestry. 8 - let behind: Int 8 + public let behind: Int 9 9 /// Whether the working copy has uncommitted changes (non-empty). 10 - let dirty: Bool 10 + public let dirty: Bool 11 11 /// If non-nil, an error occurred while checking status. 12 - let error: String? 12 + public let error: String? 13 13 14 14 /// The last time this status was successfully checked. 15 - let checkedAt: Date 15 + public let checkedAt: Date 16 + 17 + public init(ahead: Int, behind: Int, dirty: Bool, error: String?, checkedAt: Date) { 18 + self.ahead = ahead 19 + self.behind = behind 20 + self.dirty = dirty 21 + self.error = error 22 + self.checkedAt = checkedAt 23 + } 16 24 17 25 // MARK: - Convenience initializers 18 26 19 - static func synced(dirty: Bool = false, at date: Date = Date()) -> RepoStatus { 27 + public static func synced(dirty: Bool = false, at date: Date = Date()) -> RepoStatus { 20 28 RepoStatus(ahead: 0, behind: 0, dirty: dirty, error: nil, checkedAt: date) 21 29 } 22 30 23 - static func error(_ message: String, at date: Date = Date()) -> RepoStatus { 31 + public static func error(_ message: String, at date: Date = Date()) -> RepoStatus { 24 32 RepoStatus(ahead: 0, behind: 0, dirty: false, error: message, checkedAt: date) 25 33 } 26 34 27 35 // MARK: - Computed properties 28 36 29 - var isSynced: Bool { ahead == 0 && behind == 0 && error == nil } 30 - var isAhead: Bool { ahead > 0 && error == nil } 31 - var isBehind: Bool { behind > 0 && error == nil } 32 - var isDiverged: Bool { ahead > 0 && behind > 0 && error == nil } 33 - var hasError: Bool { error != nil } 37 + public var isSynced: Bool { ahead == 0 && behind == 0 && error == nil } 38 + public var isAhead: Bool { ahead > 0 && error == nil } 39 + public var isBehind: Bool { behind > 0 && error == nil } 40 + public var isDiverged: Bool { ahead > 0 && behind > 0 && error == nil } 41 + public var hasError: Bool { error != nil } 34 42 35 43 /// Compact text shown in the menu bar. 36 - var menuBarText: String { 44 + public var menuBarText: String { 37 45 if hasError { return "!" } 38 46 if isDiverged { return "\u{2191}\(ahead)\u{2193}\(behind)" } 39 47 if isAhead { return "\u{2191}\(ahead)" } ··· 42 50 } 43 51 44 52 /// Full text for the menu bar label. 45 - var menuBarLabel: String { 53 + public var menuBarLabel: String { 46 54 var label = menuBarText 47 55 if dirty && !hasError { 48 56 label += "\u{25CF}" // ● dot ··· 54 62 } 55 63 56 64 /// Human-readable status description for the dropdown. 57 - var statusDescription: String { 65 + public var statusDescription: String { 58 66 if hasError { 59 67 return "Error: \(error!)" 60 68 }
+1
Sources/HomeManagerStatus/StatusChecker.swift
··· 1 1 import Foundation 2 + import HMStatus 2 3 import SwiftUI 3 4 4 5 /// Orchestrates periodic jj status checks and publishes results for the UI.
+6 -6
Sources/HomeManagerStatus/StatusParser.swift Sources/HMStatus/StatusParser.swift
··· 1 1 import Foundation 2 2 3 3 /// Pure functions for parsing jj command output into RepoStatus. 4 - enum StatusParser { 4 + public enum StatusParser { 5 5 /// Parse the output of `jj log` that lists commit short IDs (one per line) 6 6 /// and return the count of commits. 7 7 /// 8 8 /// - Parameter output: Raw stdout from jj log command 9 9 /// - Returns: Number of commits listed 10 - static func parseCommitCount(from output: String) -> Int { 10 + public static func parseCommitCount(from output: String) -> Int { 11 11 let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) 12 12 if trimmed.isEmpty { 13 13 return 0 ··· 20 20 /// - Parameter output: Raw stdout, expected to be "clean" or "dirty" 21 21 /// - Returns: `true` if the working copy has changes, `false` if clean 22 22 /// - Throws: If the output is unexpected 23 - static func parseDirtyStatus(from output: String) throws -> Bool { 23 + public static func parseDirtyStatus(from output: String) throws -> Bool { 24 24 let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) 25 25 switch trimmed { 26 26 case "clean": ··· 40 40 /// - dirtyOutput: Output from the "dirty check" command 41 41 /// - date: Timestamp for the check (defaults to now) 42 42 /// - Returns: A fully populated RepoStatus 43 - static func parse( 43 + public static func parse( 44 44 aheadOutput: String, 45 45 behindOutput: String, 46 46 dirtyOutput: String, ··· 68 68 ) 69 69 } 70 70 71 - enum ParserError: LocalizedError { 71 + public enum ParserError: LocalizedError { 72 72 case unexpectedDirtyOutput(String) 73 73 74 - var errorDescription: String? { 74 + public var errorDescription: String? { 75 75 switch self { 76 76 case .unexpectedDirtyOutput(let output): 77 77 return "Unexpected dirty status output: '\(output)'"
+1 -1
Tests/HomeManagerStatusTests/RepoStatusTests.swift Tests/HMStatusTests/RepoStatusTests.swift
··· 1 1 import XCTest 2 2 3 - @testable import HomeManagerStatus 3 + @testable import HMStatus 4 4 5 5 final class RepoStatusTests: XCTestCase { 6 6 let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
+1 -1
Tests/HomeManagerStatusTests/StatusCheckerTests.swift Tests/HMStatusTests/StatusCheckerTests.swift
··· 1 1 import Foundation 2 2 import XCTest 3 3 4 - @testable import HomeManagerStatus 4 + @testable import HMStatus 5 5 6 6 // MARK: - Mock Command Runner 7 7
+1 -1
Tests/HomeManagerStatusTests/StatusParserTests.swift Tests/HMStatusTests/StatusParserTests.swift
··· 1 1 import Foundation 2 2 import XCTest 3 3 4 - @testable import HomeManagerStatus 4 + @testable import HMStatus 5 5 6 6 final class StatusParserTests: XCTestCase { 7 7 let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)