A macOS utility to track home-manager JJ repo status

Add HomeManagerStatus macOS menu bar app

A SwiftUI menu bar app that monitors the jj status of ~/.config/home-manager
relative to trunk(). Shows ahead/behind commit counts with SF Symbol icons,
dirty working copy indicator, and provides quick actions (refresh, open in
Ghostty, quit).

Architecture uses protocol-based dependency injection (JujutsuCommandRunner)
for testability. Includes 64 unit tests covering the model, parser, and
checker layers using Swift Testing framework.

Polls every 5 minutes. Nix-aware PATH handling for jj binary discovery.

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

+1131
+7
.gitignore
··· 1 + .build/ 2 + .swiftpm/ 3 + Package.resolved 4 + *.xcodeproj/ 5 + *.xcworkspace/ 6 + DerivedData/ 7 + *.app/
+27
Package.swift
··· 1 + // swift-tools-version: 6.0 2 + 3 + import PackageDescription 4 + 5 + let package = Package( 6 + name: "HomeManagerStatus", 7 + platforms: [ 8 + .macOS(.v14), 9 + ], 10 + dependencies: [ 11 + .package(url: "https://github.com/swiftlang/swift-testing.git", branch: "release/6.2"), 12 + ], 13 + targets: [ 14 + .executableTarget( 15 + name: "HomeManagerStatus", 16 + path: "Sources/HomeManagerStatus" 17 + ), 18 + .testTarget( 19 + name: "HomeManagerStatusTests", 20 + dependencies: [ 21 + "HomeManagerStatus", 22 + .product(name: "Testing", package: "swift-testing"), 23 + ], 24 + path: "Tests/HomeManagerStatusTests" 25 + ), 26 + ] 27 + )
+101
Sources/HomeManagerStatus/HomeManagerStatusApp.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + @main 5 + struct HomeManagerStatusApp: App { 6 + @StateObject private var checker: StatusChecker 7 + 8 + init() { 9 + // Hide from Dock — this is a menu bar-only app. 10 + // We set this programmatically because SPM executables don't have an Info.plist bundle. 11 + NSApplication.shared.setActivationPolicy(.accessory) 12 + 13 + let home = FileManager.default.homeDirectoryForCurrentUser.path 14 + let repoPath = "\(home)/.config/home-manager" 15 + let checkerInstance = StatusChecker(repoPath: repoPath) 16 + _checker = StateObject(wrappedValue: checkerInstance) 17 + 18 + // Start polling after a small delay to allow the app to finish launching. 19 + Task { @MainActor in 20 + checkerInstance.startPolling() 21 + } 22 + } 23 + 24 + var body: some Scene { 25 + MenuBarExtra { 26 + MenuContent(checker: checker) 27 + } label: { 28 + MenuBarLabel(status: checker.status) 29 + } 30 + } 31 + } 32 + 33 + // MARK: - Menu Bar Label 34 + 35 + struct MenuBarLabel: View { 36 + let status: RepoStatus 37 + 38 + var body: some View { 39 + HStack(spacing: 2) { 40 + Image(systemName: status.menuBarIcon) 41 + .symbolRenderingMode(.palette) 42 + if !status.menuBarLabel.isEmpty { 43 + Text(status.menuBarLabel) 44 + .monospacedDigit() 45 + } 46 + } 47 + } 48 + } 49 + 50 + // MARK: - Menu Content 51 + 52 + struct MenuContent: View { 53 + @ObservedObject var checker: StatusChecker 54 + 55 + private var formattedTime: String { 56 + checker.status.checkedAt.formatted(date: .omitted, time: .standard) 57 + } 58 + 59 + var body: some View { 60 + // Status section 61 + Section { 62 + ForEach( 63 + checker.status.statusDescription.components(separatedBy: "\n"), id: \.self 64 + ) { line in 65 + Text(line) 66 + } 67 + 68 + Text("Last checked: \(formattedTime)") 69 + .foregroundStyle(.secondary) 70 + } 71 + 72 + Divider() 73 + 74 + // Actions 75 + Section { 76 + Button("Refresh Now") { 77 + Task { await checker.refresh() } 78 + } 79 + .keyboardShortcut("r") 80 + 81 + Button("Open in Ghostty") { 82 + openInGhostty(path: checker.repoPath) 83 + } 84 + .keyboardShortcut("t") 85 + } 86 + 87 + Divider() 88 + 89 + Button("Quit") { 90 + NSApplication.shared.terminate(nil) 91 + } 92 + .keyboardShortcut("q") 93 + } 94 + 95 + private func openInGhostty(path: String) { 96 + let process = Process() 97 + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") 98 + process.arguments = ["-a", "Ghostty", "--args", "-e", "cd \(path) && exec $SHELL"] 99 + try? process.run() 100 + } 101 + }
+99
Sources/HomeManagerStatus/JujutsuCommandRunner.swift
··· 1 + import Foundation 2 + 3 + /// Protocol for running jj commands. Abstracted for testability. 4 + protocol JujutsuCommandRunner: Sendable { 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. 16 + 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 + let additionalPaths: [String] 20 + 21 + init(additionalPaths: [String] = []) { 22 + self.additionalPaths = additionalPaths 23 + } 24 + 25 + /// Discover a reasonable set of Nix-aware PATH entries for the current user. 26 + 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 + func run(args: [String], repoPath: String) async throws -> String { 39 + try await withCheckedThrowingContinuation { continuation in 40 + let process = Process() 41 + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") 42 + process.arguments = ["jj"] + args 43 + 44 + // Build environment with Nix-aware PATH 45 + var env = ProcessInfo.processInfo.environment 46 + let existingPath = env["PATH"] ?? "/usr/bin:/bin" 47 + let extraPaths = additionalPaths.isEmpty 48 + ? Self.nixAwarePaths() 49 + : additionalPaths 50 + env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") 51 + process.environment = env 52 + 53 + process.currentDirectoryURL = URL(fileURLWithPath: repoPath) 54 + 55 + let stdout = Pipe() 56 + let stderr = Pipe() 57 + process.standardOutput = stdout 58 + process.standardError = stderr 59 + 60 + do { 61 + try process.run() 62 + } catch { 63 + continuation.resume(throwing: CommandError.launchFailed(error.localizedDescription)) 64 + return 65 + } 66 + 67 + process.waitUntilExit() 68 + 69 + let outData = stdout.fileHandleForReading.readDataToEndOfFile() 70 + let errData = stderr.fileHandleForReading.readDataToEndOfFile() 71 + let outString = String(data: outData, encoding: .utf8) ?? "" 72 + let errString = String(data: errData, encoding: .utf8) ?? "" 73 + 74 + if process.terminationStatus != 0 { 75 + continuation.resume( 76 + throwing: CommandError.nonZeroExit( 77 + status: process.terminationStatus, 78 + stderr: errString.trimmingCharacters(in: .whitespacesAndNewlines) 79 + )) 80 + } else { 81 + continuation.resume(returning: outString) 82 + } 83 + } 84 + } 85 + 86 + enum CommandError: LocalizedError { 87 + case launchFailed(String) 88 + case nonZeroExit(status: Int32, stderr: String) 89 + 90 + var errorDescription: String? { 91 + switch self { 92 + case .launchFailed(let reason): 93 + return "Failed to launch jj: \(reason)" 94 + case .nonZeroExit(let status, let stderr): 95 + return "jj exited with status \(status): \(stderr)" 96 + } 97 + } 98 + } 99 + }
+106
Sources/HomeManagerStatus/RepoStatus.swift
··· 1 + import SwiftUI 2 + 3 + /// Represents the current status of the jj repository relative to trunk(). 4 + struct RepoStatus: Equatable, Sendable { 5 + /// Number of commits on the current working copy ancestry that are not on trunk(). 6 + let ahead: Int 7 + /// Number of commits on trunk() that are not on the current working copy ancestry. 8 + let behind: Int 9 + /// Whether the working copy has uncommitted changes (non-empty). 10 + let dirty: Bool 11 + /// If non-nil, an error occurred while checking status. 12 + let error: String? 13 + 14 + /// The last time this status was successfully checked. 15 + let checkedAt: Date 16 + 17 + // MARK: - Convenience initializers 18 + 19 + static func synced(dirty: Bool = false, at date: Date = .now) -> RepoStatus { 20 + RepoStatus(ahead: 0, behind: 0, dirty: dirty, error: nil, checkedAt: date) 21 + } 22 + 23 + static func error(_ message: String, at date: Date = .now) -> RepoStatus { 24 + RepoStatus(ahead: 0, behind: 0, dirty: false, error: message, checkedAt: date) 25 + } 26 + 27 + // MARK: - Computed properties 28 + 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 } 34 + 35 + /// The SF Symbol name to display in the menu bar. 36 + var menuBarIcon: String { 37 + if hasError { 38 + return "exclamationmark.triangle.fill" 39 + } 40 + if isDiverged { 41 + return "arrow.up.arrow.down.circle.fill" 42 + } 43 + if isAhead { 44 + return "arrow.up.circle.fill" 45 + } 46 + if isBehind { 47 + return "arrow.down.circle.fill" 48 + } 49 + return "checkmark.circle.fill" 50 + } 51 + 52 + /// The color for the menu bar icon. 53 + var menuBarColor: Color { 54 + if hasError { return .red } 55 + if isDiverged { return .yellow } 56 + if isAhead { return .blue } 57 + if isBehind { return .orange } 58 + return .green 59 + } 60 + 61 + /// Compact text shown next to the icon in the menu bar. 62 + var menuBarText: String { 63 + if hasError { return "!" } 64 + if isDiverged { return "\u{2191}\(ahead)\u{2193}\(behind)" } 65 + if isAhead { return "\u{2191}\(ahead)" } 66 + if isBehind { return "\u{2193}\(behind)" } 67 + return "" 68 + } 69 + 70 + /// Full text for the menu bar label (icon is handled separately via Image). 71 + var menuBarLabel: String { 72 + var label = menuBarText 73 + if dirty && !hasError { 74 + label += "\u{25CF}" // ● dot 75 + } 76 + return label 77 + } 78 + 79 + /// Human-readable status description for the dropdown. 80 + var statusDescription: String { 81 + if hasError { 82 + return "Error: \(error!)" 83 + } 84 + 85 + var parts: [String] = [] 86 + 87 + if isSynced { 88 + parts.append("In sync with trunk") 89 + } else { 90 + if ahead > 0 { 91 + parts.append("\(ahead) commit\(ahead == 1 ? "" : "s") ahead of trunk") 92 + } 93 + if behind > 0 { 94 + parts.append("\(behind) commit\(behind == 1 ? "" : "s") behind trunk") 95 + } 96 + } 97 + 98 + if dirty { 99 + parts.append("Working copy has changes") 100 + } else if !hasError { 101 + parts.append("Working copy is clean") 102 + } 103 + 104 + return parts.joined(separator: "\n") 105 + } 106 + }
+96
Sources/HomeManagerStatus/StatusChecker.swift
··· 1 + import Foundation 2 + import SwiftUI 3 + 4 + /// Orchestrates periodic jj status checks and publishes results for the UI. 5 + @MainActor 6 + final class StatusChecker: ObservableObject { 7 + @Published private(set) var status: RepoStatus 8 + 9 + let repoPath: String 10 + let refreshInterval: TimeInterval 11 + private let runner: JujutsuCommandRunner 12 + private var timer: Timer? 13 + 14 + init( 15 + repoPath: String, 16 + refreshInterval: TimeInterval = 300, // 5 minutes 17 + runner: JujutsuCommandRunner = LiveJujutsuCommandRunner() 18 + ) { 19 + self.repoPath = repoPath 20 + self.refreshInterval = refreshInterval 21 + self.runner = runner 22 + self.status = RepoStatus( 23 + ahead: 0, behind: 0, dirty: false, 24 + error: "Not yet checked", 25 + checkedAt: .now 26 + ) 27 + } 28 + 29 + /// Start periodic status checks. 30 + func startPolling() { 31 + // Run immediately 32 + Task { await refresh() } 33 + 34 + // Then schedule periodic checks 35 + timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { 36 + [weak self] _ in 37 + guard let self else { return } 38 + Task { @MainActor in 39 + await self.refresh() 40 + } 41 + } 42 + } 43 + 44 + /// Stop periodic status checks. 45 + func stopPolling() { 46 + timer?.invalidate() 47 + timer = nil 48 + } 49 + 50 + /// Perform a single status check. 51 + func refresh() async { 52 + let now = Date.now 53 + 54 + do { 55 + async let aheadOutput = runner.run( 56 + args: [ 57 + "log", 58 + "-r", "::@ ~ ::trunk()", 59 + "--no-graph", 60 + "-T", "commit_id.short() ++ \"\\n\"", 61 + ], 62 + repoPath: repoPath 63 + ) 64 + 65 + async let behindOutput = runner.run( 66 + args: [ 67 + "log", 68 + "-r", "::trunk() ~ ::@", 69 + "--no-graph", 70 + "-T", "commit_id.short() ++ \"\\n\"", 71 + ], 72 + repoPath: repoPath 73 + ) 74 + 75 + async let dirtyOutput = runner.run( 76 + args: [ 77 + "log", 78 + "-r", "@", 79 + "--no-graph", 80 + "-T", "if(empty, \"clean\", \"dirty\")", 81 + ], 82 + repoPath: repoPath 83 + ) 84 + 85 + let (ahead, behind, dirty) = try await (aheadOutput, behindOutput, dirtyOutput) 86 + status = StatusParser.parse( 87 + aheadOutput: ahead, 88 + behindOutput: behind, 89 + dirtyOutput: dirty, 90 + at: now 91 + ) 92 + } catch { 93 + status = RepoStatus.error(error.localizedDescription, at: now) 94 + } 95 + } 96 + }
+81
Sources/HomeManagerStatus/StatusParser.swift
··· 1 + import Foundation 2 + 3 + /// Pure functions for parsing jj command output into RepoStatus. 4 + enum StatusParser { 5 + /// Parse the output of `jj log` that lists commit short IDs (one per line) 6 + /// and return the count of commits. 7 + /// 8 + /// - Parameter output: Raw stdout from jj log command 9 + /// - Returns: Number of commits listed 10 + static func parseCommitCount(from output: String) -> Int { 11 + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) 12 + if trimmed.isEmpty { 13 + return 0 14 + } 15 + return trimmed.components(separatedBy: "\n").count 16 + } 17 + 18 + /// Parse the output of the dirty-check command. 19 + /// 20 + /// - Parameter output: Raw stdout, expected to be "clean" or "dirty" 21 + /// - Returns: `true` if the working copy has changes, `false` if clean 22 + /// - Throws: If the output is unexpected 23 + static func parseDirtyStatus(from output: String) throws -> Bool { 24 + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) 25 + switch trimmed { 26 + case "clean": 27 + return false 28 + case "dirty": 29 + return true 30 + default: 31 + throw ParserError.unexpectedDirtyOutput(trimmed) 32 + } 33 + } 34 + 35 + /// Build a RepoStatus from the three jj command outputs. 36 + /// 37 + /// - Parameters: 38 + /// - aheadOutput: Output from the "commits ahead of trunk" command 39 + /// - behindOutput: Output from the "commits behind trunk" command 40 + /// - dirtyOutput: Output from the "dirty check" command 41 + /// - date: Timestamp for the check (defaults to now) 42 + /// - Returns: A fully populated RepoStatus 43 + static func parse( 44 + aheadOutput: String, 45 + behindOutput: String, 46 + dirtyOutput: String, 47 + at date: Date = .now 48 + ) -> RepoStatus { 49 + let ahead = parseCommitCount(from: aheadOutput) 50 + let behind = parseCommitCount(from: behindOutput) 51 + 52 + let dirty: Bool 53 + do { 54 + dirty = try parseDirtyStatus(from: dirtyOutput) 55 + } catch { 56 + return RepoStatus.error( 57 + "Failed to parse working copy status: \(error.localizedDescription)", 58 + at: date 59 + ) 60 + } 61 + 62 + return RepoStatus( 63 + ahead: ahead, 64 + behind: behind, 65 + dirty: dirty, 66 + error: nil, 67 + checkedAt: date 68 + ) 69 + } 70 + 71 + enum ParserError: LocalizedError { 72 + case unexpectedDirtyOutput(String) 73 + 74 + var errorDescription: String? { 75 + switch self { 76 + case .unexpectedDirtyOutput(let output): 77 + return "Unexpected dirty status output: '\(output)'" 78 + } 79 + } 80 + } 81 + }
+225
Tests/HomeManagerStatusTests/RepoStatusTests.swift
··· 1 + import SwiftUI 2 + import Testing 3 + 4 + @testable import HomeManagerStatus 5 + 6 + struct RepoStatusTests { 7 + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 + 9 + // MARK: - Boolean state properties 10 + 11 + @Test func syncedStatus() { 12 + let status = RepoStatus(ahead: 0, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 13 + #expect(status.isSynced) 14 + #expect(!status.isAhead) 15 + #expect(!status.isBehind) 16 + #expect(!status.isDiverged) 17 + #expect(!status.hasError) 18 + } 19 + 20 + @Test func aheadOnly() { 21 + let status = RepoStatus(ahead: 3, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 22 + #expect(!status.isSynced) 23 + #expect(status.isAhead) 24 + #expect(!status.isBehind) 25 + #expect(!status.isDiverged) 26 + } 27 + 28 + @Test func behindOnly() { 29 + let status = RepoStatus(ahead: 0, behind: 5, dirty: false, error: nil, checkedAt: fixedDate) 30 + #expect(!status.isSynced) 31 + #expect(!status.isAhead) 32 + #expect(status.isBehind) 33 + #expect(!status.isDiverged) 34 + } 35 + 36 + @Test func diverged() { 37 + let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 38 + #expect(!status.isSynced) 39 + #expect(status.isAhead) 40 + #expect(status.isBehind) 41 + #expect(status.isDiverged) 42 + } 43 + 44 + @Test func errorState() { 45 + let status = RepoStatus.error("jj not found", at: fixedDate) 46 + #expect(status.hasError) 47 + #expect(!status.isSynced) 48 + #expect(!status.isAhead) 49 + #expect(!status.isBehind) 50 + #expect(!status.isDiverged) 51 + } 52 + 53 + // MARK: - Menu bar icon 54 + 55 + @Test func menuBarIconSynced() { 56 + let status = RepoStatus.synced(at: fixedDate) 57 + #expect(status.menuBarIcon == "checkmark.circle.fill") 58 + } 59 + 60 + @Test func menuBarIconAhead() { 61 + let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 62 + #expect(status.menuBarIcon == "arrow.up.circle.fill") 63 + } 64 + 65 + @Test func menuBarIconBehind() { 66 + let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 67 + #expect(status.menuBarIcon == "arrow.down.circle.fill") 68 + } 69 + 70 + @Test func menuBarIconDiverged() { 71 + let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 72 + #expect(status.menuBarIcon == "arrow.up.arrow.down.circle.fill") 73 + } 74 + 75 + @Test func menuBarIconError() { 76 + let status = RepoStatus.error("oops", at: fixedDate) 77 + #expect(status.menuBarIcon == "exclamationmark.triangle.fill") 78 + } 79 + 80 + // MARK: - Menu bar color 81 + 82 + @Test func menuBarColorSynced() { 83 + let status = RepoStatus.synced(at: fixedDate) 84 + #expect(status.menuBarColor == .green) 85 + } 86 + 87 + @Test func menuBarColorAhead() { 88 + let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 89 + #expect(status.menuBarColor == .blue) 90 + } 91 + 92 + @Test func menuBarColorBehind() { 93 + let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 94 + #expect(status.menuBarColor == .orange) 95 + } 96 + 97 + @Test func menuBarColorDiverged() { 98 + let status = RepoStatus(ahead: 1, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 99 + #expect(status.menuBarColor == .yellow) 100 + } 101 + 102 + @Test func menuBarColorError() { 103 + let status = RepoStatus.error("oops", at: fixedDate) 104 + #expect(status.menuBarColor == .red) 105 + } 106 + 107 + // MARK: - Menu bar text 108 + 109 + @Test func menuBarTextSynced() { 110 + let status = RepoStatus.synced(at: fixedDate) 111 + #expect(status.menuBarText == "") 112 + } 113 + 114 + @Test func menuBarTextAhead() { 115 + let status = RepoStatus(ahead: 2, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 116 + #expect(status.menuBarText == "\u{2191}2") 117 + } 118 + 119 + @Test func menuBarTextBehind() { 120 + let status = RepoStatus(ahead: 0, behind: 7, dirty: false, error: nil, checkedAt: fixedDate) 121 + #expect(status.menuBarText == "\u{2193}7") 122 + } 123 + 124 + @Test func menuBarTextDiverged() { 125 + let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 126 + #expect(status.menuBarText == "\u{2191}2\u{2193}3") 127 + } 128 + 129 + @Test func menuBarTextError() { 130 + let status = RepoStatus.error("oops", at: fixedDate) 131 + #expect(status.menuBarText == "!") 132 + } 133 + 134 + @Test func menuBarTextLargeNumbers() { 135 + let status = RepoStatus( 136 + ahead: 42, behind: 100, dirty: false, error: nil, checkedAt: fixedDate) 137 + #expect(status.menuBarText == "\u{2191}42\u{2193}100") 138 + } 139 + 140 + // MARK: - Menu bar label (text + dirty dot) 141 + 142 + @Test func menuBarLabelSyncedClean() { 143 + let status = RepoStatus.synced(dirty: false, at: fixedDate) 144 + #expect(status.menuBarLabel == "") 145 + } 146 + 147 + @Test func menuBarLabelSyncedDirty() { 148 + let status = RepoStatus.synced(dirty: true, at: fixedDate) 149 + #expect(status.menuBarLabel == "\u{25CF}") 150 + } 151 + 152 + @Test func menuBarLabelAheadDirty() { 153 + let status = RepoStatus(ahead: 2, behind: 0, dirty: true, error: nil, checkedAt: fixedDate) 154 + #expect(status.menuBarLabel == "\u{2191}2\u{25CF}") 155 + } 156 + 157 + @Test func menuBarLabelErrorNoDot() { 158 + let status = RepoStatus( 159 + ahead: 0, behind: 0, dirty: true, error: "err", checkedAt: fixedDate) 160 + // Error state should not show dirty dot 161 + #expect(status.menuBarLabel == "!") 162 + } 163 + 164 + // MARK: - Status description 165 + 166 + @Test func statusDescriptionSynced() { 167 + let status = RepoStatus.synced(at: fixedDate) 168 + #expect(status.statusDescription.contains("In sync with trunk")) 169 + #expect(status.statusDescription.contains("Working copy is clean")) 170 + } 171 + 172 + @Test func statusDescriptionAhead() { 173 + let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 174 + #expect(status.statusDescription.contains("1 commit ahead of trunk")) 175 + } 176 + 177 + @Test func statusDescriptionAheadPlural() { 178 + let status = RepoStatus(ahead: 5, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 179 + #expect(status.statusDescription.contains("5 commits ahead of trunk")) 180 + } 181 + 182 + @Test func statusDescriptionBehind() { 183 + let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 184 + #expect(status.statusDescription.contains("1 commit behind trunk")) 185 + } 186 + 187 + @Test func statusDescriptionBehindPlural() { 188 + let status = RepoStatus(ahead: 0, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 189 + #expect(status.statusDescription.contains("3 commits behind trunk")) 190 + } 191 + 192 + @Test func statusDescriptionDiverged() { 193 + let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 194 + #expect(status.statusDescription.contains("2 commits ahead of trunk")) 195 + #expect(status.statusDescription.contains("3 commits behind trunk")) 196 + } 197 + 198 + @Test func statusDescriptionDirty() { 199 + let status = RepoStatus(ahead: 0, behind: 0, dirty: true, error: nil, checkedAt: fixedDate) 200 + #expect(status.statusDescription.contains("Working copy has changes")) 201 + } 202 + 203 + @Test func statusDescriptionError() { 204 + let status = RepoStatus.error("jj not found", at: fixedDate) 205 + #expect(status.statusDescription.contains("Error: jj not found")) 206 + } 207 + 208 + // MARK: - Convenience initializers 209 + 210 + @Test func syncedConvenience() { 211 + let status = RepoStatus.synced(dirty: true, at: fixedDate) 212 + #expect(status.ahead == 0) 213 + #expect(status.behind == 0) 214 + #expect(status.dirty) 215 + #expect(status.error == nil) 216 + } 217 + 218 + @Test func errorConvenience() { 219 + let status = RepoStatus.error("boom", at: fixedDate) 220 + #expect(status.ahead == 0) 221 + #expect(status.behind == 0) 222 + #expect(!status.dirty) 223 + #expect(status.error == "boom") 224 + } 225 + }
+168
Tests/HomeManagerStatusTests/StatusCheckerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import HomeManagerStatus 5 + 6 + // MARK: - Mock Command Runner 7 + 8 + /// A mock JujutsuCommandRunner that returns predefined responses based on arguments. 9 + struct MockJujutsuCommandRunner: JujutsuCommandRunner, Sendable { 10 + let responses: @Sendable ([String]) -> Result<String, Error> 11 + 12 + init(responses: @escaping @Sendable ([String]) -> Result<String, Error>) { 13 + self.responses = responses 14 + } 15 + 16 + /// Create a mock that returns fixed outputs for ahead, behind, and dirty queries. 17 + static func fixed(ahead: String, behind: String, dirty: String) -> MockJujutsuCommandRunner { 18 + MockJujutsuCommandRunner { args in 19 + // Identify which command is being run by inspecting the revset argument 20 + if let revsetIndex = args.firstIndex(of: "-r"), 21 + revsetIndex + 1 < args.count 22 + { 23 + let revset = args[revsetIndex + 1] 24 + if revset.contains("::@ ~ ::trunk()") { 25 + return .success(ahead) 26 + } else if revset.contains("::trunk() ~ ::@") { 27 + return .success(behind) 28 + } else if revset == "@" { 29 + return .success(dirty) 30 + } 31 + } 32 + return .failure(MockError.unexpectedArgs(args)) 33 + } 34 + } 35 + 36 + /// Create a mock that always fails. 37 + static func failing(error: String) -> MockJujutsuCommandRunner { 38 + MockJujutsuCommandRunner { _ in 39 + .failure(MockError.simulated(error)) 40 + } 41 + } 42 + 43 + func run(args: [String], repoPath: String) async throws -> String { 44 + switch responses(args) { 45 + case .success(let output): 46 + return output 47 + case .failure(let error): 48 + throw error 49 + } 50 + } 51 + 52 + enum MockError: LocalizedError { 53 + case unexpectedArgs([String]) 54 + case simulated(String) 55 + 56 + var errorDescription: String? { 57 + switch self { 58 + case .unexpectedArgs(let args): 59 + return "Unexpected args: \(args)" 60 + case .simulated(let msg): 61 + return msg 62 + } 63 + } 64 + } 65 + } 66 + 67 + // MARK: - StatusChecker Tests 68 + 69 + @Suite(.serialized) 70 + struct StatusCheckerTests { 71 + @Test @MainActor func refreshSynced() async { 72 + let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 73 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 74 + 75 + await checker.refresh() 76 + 77 + #expect(checker.status.isSynced) 78 + #expect(!checker.status.dirty) 79 + #expect(checker.status.error == nil) 80 + } 81 + 82 + @Test @MainActor func refreshAhead() async { 83 + let runner = MockJujutsuCommandRunner.fixed( 84 + ahead: "abc123\ndef456\n", behind: "", dirty: "clean") 85 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 86 + 87 + await checker.refresh() 88 + 89 + #expect(checker.status.ahead == 2) 90 + #expect(checker.status.behind == 0) 91 + #expect(checker.status.isAhead) 92 + } 93 + 94 + @Test @MainActor func refreshBehind() async { 95 + let runner = MockJujutsuCommandRunner.fixed( 96 + ahead: "", behind: "abc123\ndef456\nghi789\n", dirty: "clean") 97 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 98 + 99 + await checker.refresh() 100 + 101 + #expect(checker.status.ahead == 0) 102 + #expect(checker.status.behind == 3) 103 + #expect(checker.status.isBehind) 104 + } 105 + 106 + @Test @MainActor func refreshDiverged() async { 107 + let runner = MockJujutsuCommandRunner.fixed( 108 + ahead: "abc123\n", behind: "def456\nghi789\n", dirty: "dirty") 109 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 110 + 111 + await checker.refresh() 112 + 113 + #expect(checker.status.ahead == 1) 114 + #expect(checker.status.behind == 2) 115 + #expect(checker.status.dirty) 116 + #expect(checker.status.isDiverged) 117 + } 118 + 119 + @Test @MainActor func refreshDirtyOnly() async { 120 + let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "dirty") 121 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 122 + 123 + await checker.refresh() 124 + 125 + #expect(checker.status.isSynced) 126 + #expect(checker.status.dirty) 127 + } 128 + 129 + @Test @MainActor func refreshWithError() async { 130 + let runner = MockJujutsuCommandRunner.failing(error: "jj: command not found") 131 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 132 + 133 + await checker.refresh() 134 + 135 + #expect(checker.status.hasError) 136 + #expect(checker.status.error?.contains("jj: command not found") == true) 137 + } 138 + 139 + @Test @MainActor func initialStatusIsNotYetChecked() { 140 + let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 141 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 142 + 143 + #expect(checker.status.hasError) 144 + #expect(checker.status.error == "Not yet checked") 145 + } 146 + 147 + @Test @MainActor func repoPathIsPreserved() { 148 + let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 149 + let checker = StatusChecker( 150 + repoPath: "/home/user/.config/home-manager", refreshInterval: 300, runner: runner) 151 + 152 + #expect(checker.repoPath == "/home/user/.config/home-manager") 153 + } 154 + 155 + @Test @MainActor func refreshUpdatesStatus() async { 156 + let runner = MockJujutsuCommandRunner.fixed( 157 + ahead: "", behind: "abc123\n", dirty: "clean") 158 + let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 159 + 160 + // Initial state is error (not yet checked) 161 + #expect(checker.status.hasError) 162 + 163 + // After refresh, should be behind 164 + await checker.refresh() 165 + #expect(checker.status.isBehind) 166 + #expect(checker.status.behind == 1) 167 + } 168 + }
+162
Tests/HomeManagerStatusTests/StatusParserTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import HomeManagerStatus 5 + 6 + struct StatusParserTests { 7 + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 + 9 + // MARK: - parseCommitCount 10 + 11 + @Test func parseCommitCountEmpty() { 12 + #expect(StatusParser.parseCommitCount(from: "") == 0) 13 + } 14 + 15 + @Test func parseCommitCountWhitespaceOnly() { 16 + #expect(StatusParser.parseCommitCount(from: " \n \n ") == 0) 17 + } 18 + 19 + @Test func parseCommitCountSingleCommit() { 20 + #expect(StatusParser.parseCommitCount(from: "abc123\n") == 1) 21 + } 22 + 23 + @Test func parseCommitCountSingleCommitNoNewline() { 24 + #expect(StatusParser.parseCommitCount(from: "abc123") == 1) 25 + } 26 + 27 + @Test func parseCommitCountMultipleCommits() { 28 + let output = "abc123\ndef456\nghi789\n" 29 + #expect(StatusParser.parseCommitCount(from: output) == 3) 30 + } 31 + 32 + @Test func parseCommitCountMultipleCommitsNoTrailingNewline() { 33 + let output = "abc123\ndef456\nghi789" 34 + #expect(StatusParser.parseCommitCount(from: output) == 3) 35 + } 36 + 37 + @Test func parseCommitCountWithLeadingTrailingWhitespace() { 38 + let output = "\n abc123\ndef456 \n" 39 + // After trimming: "abc123\ndef456" => 2 lines 40 + #expect(StatusParser.parseCommitCount(from: output) == 2) 41 + } 42 + 43 + // MARK: - parseDirtyStatus 44 + 45 + @Test func parseDirtyStatusClean() throws { 46 + let result = try StatusParser.parseDirtyStatus(from: "clean") 47 + #expect(result == false) 48 + } 49 + 50 + @Test func parseDirtyStatusDirty() throws { 51 + let result = try StatusParser.parseDirtyStatus(from: "dirty") 52 + #expect(result == true) 53 + } 54 + 55 + @Test func parseDirtyStatusCleanWithWhitespace() throws { 56 + let result = try StatusParser.parseDirtyStatus(from: " clean \n") 57 + #expect(result == false) 58 + } 59 + 60 + @Test func parseDirtyStatusDirtyWithWhitespace() throws { 61 + let result = try StatusParser.parseDirtyStatus(from: "\ndirty\n") 62 + #expect(result == true) 63 + } 64 + 65 + @Test func parseDirtyStatusUnexpectedOutput() { 66 + #expect(throws: StatusParser.ParserError.self) { 67 + try StatusParser.parseDirtyStatus(from: "unknown") 68 + } 69 + } 70 + 71 + @Test func parseDirtyStatusEmptyOutput() { 72 + #expect(throws: StatusParser.ParserError.self) { 73 + try StatusParser.parseDirtyStatus(from: "") 74 + } 75 + } 76 + 77 + // MARK: - parse (combined) 78 + 79 + @Test func parseInSync() { 80 + let status = StatusParser.parse( 81 + aheadOutput: "", 82 + behindOutput: "", 83 + dirtyOutput: "clean", 84 + at: fixedDate 85 + ) 86 + #expect(status.ahead == 0) 87 + #expect(status.behind == 0) 88 + #expect(!status.dirty) 89 + #expect(status.error == nil) 90 + #expect(status.isSynced) 91 + } 92 + 93 + @Test func parseAheadOnly() { 94 + let status = StatusParser.parse( 95 + aheadOutput: "abc123\ndef456\n", 96 + behindOutput: "", 97 + dirtyOutput: "clean", 98 + at: fixedDate 99 + ) 100 + #expect(status.ahead == 2) 101 + #expect(status.behind == 0) 102 + #expect(!status.dirty) 103 + #expect(status.isAhead) 104 + } 105 + 106 + @Test func parseBehindOnly() { 107 + let status = StatusParser.parse( 108 + aheadOutput: "", 109 + behindOutput: "abc123\ndef456\nghi789\n", 110 + dirtyOutput: "clean", 111 + at: fixedDate 112 + ) 113 + #expect(status.ahead == 0) 114 + #expect(status.behind == 3) 115 + #expect(status.isBehind) 116 + } 117 + 118 + @Test func parseDiverged() { 119 + let status = StatusParser.parse( 120 + aheadOutput: "abc123\n", 121 + behindOutput: "def456\nghi789\n", 122 + dirtyOutput: "dirty", 123 + at: fixedDate 124 + ) 125 + #expect(status.ahead == 1) 126 + #expect(status.behind == 2) 127 + #expect(status.dirty) 128 + #expect(status.isDiverged) 129 + } 130 + 131 + @Test func parseDirtyWorkingCopy() { 132 + let status = StatusParser.parse( 133 + aheadOutput: "", 134 + behindOutput: "", 135 + dirtyOutput: "dirty", 136 + at: fixedDate 137 + ) 138 + #expect(status.isSynced) 139 + #expect(status.dirty) 140 + } 141 + 142 + @Test func parseInvalidDirtyOutputReturnsError() { 143 + let status = StatusParser.parse( 144 + aheadOutput: "abc123\n", 145 + behindOutput: "", 146 + dirtyOutput: "garbage", 147 + at: fixedDate 148 + ) 149 + #expect(status.hasError) 150 + #expect(status.error?.contains("Unexpected dirty status output") == true) 151 + } 152 + 153 + @Test func parsePreservesDate() { 154 + let status = StatusParser.parse( 155 + aheadOutput: "", 156 + behindOutput: "", 157 + dirtyOutput: "clean", 158 + at: fixedDate 159 + ) 160 + #expect(status.checkedAt == fixedDate) 161 + } 162 + }
+59
scripts/bundle.sh
··· 1 + #!/bin/bash 2 + set -euo pipefail 3 + 4 + # Build the app and create a proper macOS .app bundle 5 + # MenuBarExtra requires a bundled application to render in the menu bar. 6 + 7 + # Always run from project root 8 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 9 + PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" 10 + cd "${PROJECT_DIR}" 11 + 12 + APP_NAME="HomeManagerStatus" 13 + BUILD_DIR=".build/release" 14 + APP_BUNDLE="${APP_NAME}.app" 15 + CONTENTS_DIR="${APP_BUNDLE}/Contents" 16 + MACOS_DIR="${CONTENTS_DIR}/MacOS" 17 + 18 + echo "Building release binary..." 19 + swift build -c release 20 + 21 + echo "Creating app bundle..." 22 + rm -rf "${APP_BUNDLE}" 23 + mkdir -p "${MACOS_DIR}" 24 + 25 + # Copy the binary 26 + cp "${BUILD_DIR}/${APP_NAME}" "${MACOS_DIR}/${APP_NAME}" 27 + 28 + # Create Info.plist 29 + cat >"${CONTENTS_DIR}/Info.plist" <<'PLIST' 30 + <?xml version="1.0" encoding="UTF-8"?> 31 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 32 + <plist version="1.0"> 33 + <dict> 34 + <key>CFBundleExecutable</key> 35 + <string>HomeManagerStatus</string> 36 + <key>CFBundleIdentifier</key> 37 + <string>dev.nolith.HomeManagerStatus</string> 38 + <key>CFBundleName</key> 39 + <string>HomeManagerStatus</string> 40 + <key>CFBundleDisplayName</key> 41 + <string>Home Manager Status</string> 42 + <key>CFBundleVersion</key> 43 + <string>1</string> 44 + <key>CFBundleShortVersionString</key> 45 + <string>1.0.0</string> 46 + <key>CFBundlePackageType</key> 47 + <string>APPL</string> 48 + <key>LSMinimumSystemVersion</key> 49 + <string>14.0</string> 50 + <key>LSUIElement</key> 51 + <true/> 52 + </dict> 53 + </plist> 54 + PLIST 55 + 56 + echo "Done! App bundle created at: ${APP_BUNDLE}" 57 + echo "" 58 + echo "To run: open ${APP_BUNDLE}" 59 + echo "To install: cp -r ${APP_BUNDLE} /Applications/"