A macOS utility to track home-manager JJ repo status

Add cross-platform StatusChecker and refactor macOS polling

Add StatusChecker.check() to HMStatus library as a single-shot,
cross-platform status check function. Refactor the macOS-specific
polling class (renamed to AppStatusChecker) to delegate to it.
Rewrite StatusCheckerTests to test the cross-platform function.

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

+146 -172
+59
Sources/HMStatus/StatusChecker.swift
··· 1 + import Foundation 2 + 3 + /// Single-shot jj status check. Cross-platform (no Apple frameworks). 4 + public enum StatusChecker { 5 + /// Perform a single status check against a jj repository. 6 + /// 7 + /// - Parameters: 8 + /// - repoPath: Path to the jj repository 9 + /// - runner: Command runner to use (defaults to LiveJujutsuCommandRunner) 10 + /// - date: Timestamp for the check (defaults to now) 11 + /// - Returns: A fully populated RepoStatus 12 + public static func check( 13 + repoPath: String, 14 + runner: JujutsuCommandRunner = LiveJujutsuCommandRunner(), 15 + at date: Date = Date() 16 + ) async -> RepoStatus { 17 + do { 18 + async let aheadOutput = runner.run( 19 + args: [ 20 + "log", 21 + "-r", "::@ ~ ::trunk()", 22 + "--no-graph", 23 + "-T", "commit_id.short() ++ \"\\n\"", 24 + ], 25 + repoPath: repoPath 26 + ) 27 + 28 + async let behindOutput = runner.run( 29 + args: [ 30 + "log", 31 + "-r", "::trunk() ~ ::@", 32 + "--no-graph", 33 + "-T", "commit_id.short() ++ \"\\n\"", 34 + ], 35 + repoPath: repoPath 36 + ) 37 + 38 + async let dirtyOutput = runner.run( 39 + args: [ 40 + "log", 41 + "-r", "@", 42 + "--no-graph", 43 + "-T", "if(empty, \"clean\", \"dirty\")", 44 + ], 45 + repoPath: repoPath 46 + ) 47 + 48 + let (ahead, behind, dirty) = try await (aheadOutput, behindOutput, dirtyOutput) 49 + return StatusParser.parse( 50 + aheadOutput: ahead, 51 + behindOutput: behind, 52 + dirtyOutput: dirty, 53 + at: date 54 + ) 55 + } catch { 56 + return RepoStatus.error(error.localizedDescription, at: date) 57 + } 58 + } 59 + }
+55
Sources/HomeManagerStatus/AppStatusChecker.swift
··· 1 + import Foundation 2 + import HMStatus 3 + import SwiftUI 4 + 5 + /// Orchestrates periodic jj status checks and publishes results for the UI. 6 + @MainActor 7 + final class AppStatusChecker: ObservableObject { 8 + @Published private(set) var status: RepoStatus 9 + 10 + let repoPath: String 11 + let refreshInterval: TimeInterval 12 + private let runner: JujutsuCommandRunner 13 + private var timer: Timer? 14 + 15 + init( 16 + repoPath: String, 17 + refreshInterval: TimeInterval = 300, // 5 minutes 18 + runner: JujutsuCommandRunner = LiveJujutsuCommandRunner() 19 + ) { 20 + self.repoPath = repoPath 21 + self.refreshInterval = refreshInterval 22 + self.runner = runner 23 + self.status = RepoStatus( 24 + ahead: 0, behind: 0, dirty: false, 25 + error: "Not yet checked", 26 + checkedAt: Date() 27 + ) 28 + } 29 + 30 + /// Start periodic status checks. 31 + func startPolling() { 32 + // Run immediately 33 + Task { await refresh() } 34 + 35 + // Then schedule periodic checks 36 + timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { 37 + [weak self] _ in 38 + guard let self = self else { return } 39 + Task { @MainActor in 40 + await self.refresh() 41 + } 42 + } 43 + } 44 + 45 + /// Stop periodic status checks. 46 + func stopPolling() { 47 + timer?.invalidate() 48 + timer = nil 49 + } 50 + 51 + /// Perform a single status check. 52 + func refresh() async { 53 + status = await StatusChecker.check(repoPath: repoPath, runner: runner) 54 + } 55 + }
+3 -3
Sources/HomeManagerStatus/HomeManagerStatusApp.swift
··· 4 4 5 5 @main 6 6 struct HomeManagerStatusApp: App { 7 - @StateObject private var checker: StatusChecker 7 + @StateObject private var checker: AppStatusChecker 8 8 9 9 init() { 10 10 // Hide from Dock — this is a menu bar-only app. ··· 13 13 14 14 let home = FileManager.default.homeDirectoryForCurrentUser.path 15 15 let repoPath = "\(home)/.config/home-manager" 16 - let checkerInstance = StatusChecker(repoPath: repoPath) 16 + let checkerInstance = AppStatusChecker(repoPath: repoPath) 17 17 _checker = StateObject(wrappedValue: checkerInstance) 18 18 19 19 // Start polling after a small delay to allow the app to finish launching. ··· 45 45 // MARK: - Menu Content 46 46 47 47 struct MenuContent: View { 48 - @ObservedObject var checker: StatusChecker 48 + @ObservedObject var checker: AppStatusChecker 49 49 50 50 private var formattedTime: String { 51 51 checker.status.checkedAt.formatted(date: .omitted, time: .standard)
-97
Sources/HomeManagerStatus/StatusChecker.swift
··· 1 - import Foundation 2 - import HMStatus 3 - import SwiftUI 4 - 5 - /// Orchestrates periodic jj status checks and publishes results for the UI. 6 - @MainActor 7 - final class StatusChecker: ObservableObject { 8 - @Published private(set) var status: RepoStatus 9 - 10 - let repoPath: String 11 - let refreshInterval: TimeInterval 12 - private let runner: JujutsuCommandRunner 13 - private var timer: Timer? 14 - 15 - init( 16 - repoPath: String, 17 - refreshInterval: TimeInterval = 300, // 5 minutes 18 - runner: JujutsuCommandRunner = LiveJujutsuCommandRunner() 19 - ) { 20 - self.repoPath = repoPath 21 - self.refreshInterval = refreshInterval 22 - self.runner = runner 23 - self.status = RepoStatus( 24 - ahead: 0, behind: 0, dirty: false, 25 - error: "Not yet checked", 26 - checkedAt: Date() 27 - ) 28 - } 29 - 30 - /// Start periodic status checks. 31 - func startPolling() { 32 - // Run immediately 33 - Task { await refresh() } 34 - 35 - // Then schedule periodic checks 36 - timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { 37 - [weak self] _ in 38 - guard let self = self else { return } 39 - Task { @MainActor in 40 - await self.refresh() 41 - } 42 - } 43 - } 44 - 45 - /// Stop periodic status checks. 46 - func stopPolling() { 47 - timer?.invalidate() 48 - timer = nil 49 - } 50 - 51 - /// Perform a single status check. 52 - func refresh() async { 53 - let now = Date() 54 - 55 - do { 56 - async let aheadOutput = runner.run( 57 - args: [ 58 - "log", 59 - "-r", "::@ ~ ::trunk()", 60 - "--no-graph", 61 - "-T", "commit_id.short() ++ \"\\n\"", 62 - ], 63 - repoPath: repoPath 64 - ) 65 - 66 - async let behindOutput = runner.run( 67 - args: [ 68 - "log", 69 - "-r", "::trunk() ~ ::@", 70 - "--no-graph", 71 - "-T", "commit_id.short() ++ \"\\n\"", 72 - ], 73 - repoPath: repoPath 74 - ) 75 - 76 - async let dirtyOutput = runner.run( 77 - args: [ 78 - "log", 79 - "-r", "@", 80 - "--no-graph", 81 - "-T", "if(empty, \"clean\", \"dirty\")", 82 - ], 83 - repoPath: repoPath 84 - ) 85 - 86 - let (ahead, behind, dirty) = try await (aheadOutput, behindOutput, dirtyOutput) 87 - status = StatusParser.parse( 88 - aheadOutput: ahead, 89 - behindOutput: behind, 90 - dirtyOutput: dirty, 91 - at: now 92 - ) 93 - } catch { 94 - status = RepoStatus.error(error.localizedDescription, at: now) 95 - } 96 - } 97 - }
+29 -72
Tests/HMStatusTests/StatusCheckerTests.swift
··· 66 66 67 67 // MARK: - StatusChecker Tests 68 68 69 - @MainActor 70 69 final class StatusCheckerTests: XCTestCase { 71 70 72 - func testRefreshSynced() async { 71 + func testCheckSynced() async { 73 72 let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 74 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 73 + let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 75 74 76 - await checker.refresh() 77 - 78 - XCTAssertTrue(checker.status.isSynced) 79 - XCTAssertFalse(checker.status.dirty) 80 - XCTAssertNil(checker.status.error) 75 + XCTAssertTrue(status.isSynced) 76 + XCTAssertFalse(status.dirty) 77 + XCTAssertNil(status.error) 81 78 } 82 79 83 - func testRefreshAhead() async { 80 + func testCheckAhead() async { 84 81 let runner = MockJujutsuCommandRunner.fixed( 85 82 ahead: "abc123\ndef456\n", behind: "", dirty: "clean") 86 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 83 + let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 87 84 88 - await checker.refresh() 89 - 90 - XCTAssertEqual(checker.status.ahead, 2) 91 - XCTAssertEqual(checker.status.behind, 0) 92 - XCTAssertTrue(checker.status.isAhead) 85 + XCTAssertEqual(status.ahead, 2) 86 + XCTAssertEqual(status.behind, 0) 87 + XCTAssertTrue(status.isAhead) 93 88 } 94 89 95 - func testRefreshBehind() async { 90 + func testCheckBehind() async { 96 91 let runner = MockJujutsuCommandRunner.fixed( 97 92 ahead: "", behind: "abc123\ndef456\nghi789\n", dirty: "clean") 98 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 93 + let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 99 94 100 - await checker.refresh() 101 - 102 - XCTAssertEqual(checker.status.ahead, 0) 103 - XCTAssertEqual(checker.status.behind, 3) 104 - XCTAssertTrue(checker.status.isBehind) 95 + XCTAssertEqual(status.ahead, 0) 96 + XCTAssertEqual(status.behind, 3) 97 + XCTAssertTrue(status.isBehind) 105 98 } 106 99 107 - func testRefreshDiverged() async { 100 + func testCheckDiverged() async { 108 101 let runner = MockJujutsuCommandRunner.fixed( 109 102 ahead: "abc123\n", behind: "def456\nghi789\n", dirty: "dirty") 110 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 111 - 112 - await checker.refresh() 103 + let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 113 104 114 - XCTAssertEqual(checker.status.ahead, 1) 115 - XCTAssertEqual(checker.status.behind, 2) 116 - XCTAssertTrue(checker.status.dirty) 117 - XCTAssertTrue(checker.status.isDiverged) 105 + XCTAssertEqual(status.ahead, 1) 106 + XCTAssertEqual(status.behind, 2) 107 + XCTAssertTrue(status.dirty) 108 + XCTAssertTrue(status.isDiverged) 118 109 } 119 110 120 - func testRefreshDirtyOnly() async { 111 + func testCheckDirtyOnly() async { 121 112 let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "dirty") 122 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 113 + let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 123 114 124 - await checker.refresh() 125 - 126 - XCTAssertTrue(checker.status.isSynced) 127 - XCTAssertTrue(checker.status.dirty) 115 + XCTAssertTrue(status.isSynced) 116 + XCTAssertTrue(status.dirty) 128 117 } 129 118 130 - func testRefreshWithError() async { 119 + func testCheckWithError() async { 131 120 let runner = MockJujutsuCommandRunner.failing(error: "jj: command not found") 132 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 133 - 134 - await checker.refresh() 135 - 136 - XCTAssertTrue(checker.status.hasError) 137 - XCTAssertTrue(checker.status.error?.contains("jj: command not found") == true) 138 - } 139 - 140 - func testInitialStatusIsNotYetChecked() { 141 - let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 142 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 143 - 144 - XCTAssertTrue(checker.status.hasError) 145 - XCTAssertEqual(checker.status.error, "Not yet checked") 146 - } 121 + let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 147 122 148 - func testRepoPathIsPreserved() { 149 - let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 150 - let checker = StatusChecker( 151 - repoPath: "/home/user/.config/home-manager", refreshInterval: 300, runner: runner) 152 - 153 - XCTAssertEqual(checker.repoPath, "/home/user/.config/home-manager") 154 - } 155 - 156 - func testRefreshUpdatesStatus() async { 157 - let runner = MockJujutsuCommandRunner.fixed( 158 - ahead: "", behind: "abc123\n", dirty: "clean") 159 - let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 160 - 161 - // Initial state is error (not yet checked) 162 - XCTAssertTrue(checker.status.hasError) 163 - 164 - // After refresh, should be behind 165 - await checker.refresh() 166 - XCTAssertTrue(checker.status.isBehind) 167 - XCTAssertEqual(checker.status.behind, 1) 123 + XCTAssertTrue(status.hasError) 124 + XCTAssertTrue(status.error?.contains("jj: command not found") == true) 168 125 } 169 126 }