A macOS utility to track home-manager JJ repo status

Add flake.nix with flake-parts and treefmt

Minimal Nix flake using flake-parts with:
- nixpkgs unstable
- treefmt-nix with nixfmt, shfmt, and swift-format
- devShell with swift-format
- macOS only (aarch64-darwin, x86_64-darwin)

Also applies swift-format to all existing source files.

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

+942 -815
+21 -21
Package.swift
··· 3 3 import PackageDescription 4 4 5 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 - ] 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 27 )
+67 -67
Sources/HomeManagerStatus/HomeManagerStatusApp.swift
··· 3 3 4 4 @main 5 5 struct HomeManagerStatusApp: App { 6 - @StateObject private var checker: StatusChecker 6 + @StateObject private var checker: StatusChecker 7 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) 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 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) 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 17 18 - // Start polling after a small delay to allow the app to finish launching. 19 - Task { @MainActor in 20 - checkerInstance.startPolling() 21 - } 18 + // Start polling after a small delay to allow the app to finish launching. 19 + Task { @MainActor in 20 + checkerInstance.startPolling() 22 21 } 22 + } 23 23 24 - var body: some Scene { 25 - MenuBarExtra { 26 - MenuContent(checker: checker) 27 - } label: { 28 - MenuBarLabel(status: checker.status) 29 - } 24 + var body: some Scene { 25 + MenuBarExtra { 26 + MenuContent(checker: checker) 27 + } label: { 28 + MenuBarLabel(status: checker.status) 30 29 } 30 + } 31 31 } 32 32 33 33 // MARK: - Menu Bar Label 34 34 35 35 struct MenuBarLabel: View { 36 - let status: RepoStatus 36 + let status: RepoStatus 37 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 - } 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 + } 47 46 } 47 + } 48 48 } 49 49 50 50 // MARK: - Menu Content 51 51 52 52 struct MenuContent: View { 53 - @ObservedObject var checker: StatusChecker 53 + @ObservedObject var checker: StatusChecker 54 54 55 - private var formattedTime: String { 56 - checker.status.checkedAt.formatted(date: .omitted, time: .standard) 57 - } 55 + private var formattedTime: String { 56 + checker.status.checkedAt.formatted(date: .omitted, time: .standard) 57 + } 58 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 - } 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 67 68 - Text("Last checked: \(formattedTime)") 69 - .foregroundStyle(.secondary) 70 - } 68 + Text("Last checked: \(formattedTime)") 69 + .foregroundStyle(.secondary) 70 + } 71 71 72 - Divider() 72 + Divider() 73 73 74 - // Actions 75 - Section { 76 - Button("Refresh Now") { 77 - Task { await checker.refresh() } 78 - } 79 - .keyboardShortcut("r") 74 + // Actions 75 + Section { 76 + Button("Refresh Now") { 77 + Task { await checker.refresh() } 78 + } 79 + .keyboardShortcut("r") 80 80 81 - Button("Open in Ghostty") { 82 - openInGhostty(path: checker.repoPath) 83 - } 84 - .keyboardShortcut("t") 85 - } 81 + Button("Open in Ghostty") { 82 + openInGhostty(path: checker.repoPath) 83 + } 84 + .keyboardShortcut("t") 85 + } 86 86 87 - Divider() 87 + Divider() 88 88 89 - Button("Quit") { 90 - NSApplication.shared.terminate(nil) 91 - } 92 - .keyboardShortcut("q") 89 + Button("Quit") { 90 + NSApplication.shared.terminate(nil) 93 91 } 92 + .keyboardShortcut("q") 93 + } 94 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 - } 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 101 }
+77 -76
Sources/HomeManagerStatus/JujutsuCommandRunner.swift
··· 2 2 3 3 /// Protocol for running jj commands. Abstracted for testability. 4 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 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 13 } 14 14 15 15 /// Real implementation that executes jj via Process. 16 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] 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 20 21 - init(additionalPaths: [String] = []) { 22 - self.additionalPaths = additionalPaths 23 - } 21 + init(additionalPaths: [String] = []) { 22 + self.additionalPaths = additionalPaths 23 + } 24 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 - } 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 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 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 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 44 + // Build environment with Nix-aware PATH 45 + var env = ProcessInfo.processInfo.environment 46 + let existingPath = env["PATH"] ?? "/usr/bin:/bin" 47 + let extraPaths = 48 + additionalPaths.isEmpty 49 + ? Self.nixAwarePaths() 50 + : additionalPaths 51 + env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") 52 + process.environment = env 52 53 53 - process.currentDirectoryURL = URL(fileURLWithPath: repoPath) 54 + process.currentDirectoryURL = URL(fileURLWithPath: repoPath) 54 55 55 - let stdout = Pipe() 56 - let stderr = Pipe() 57 - process.standardOutput = stdout 58 - process.standardError = stderr 56 + let stdout = Pipe() 57 + let stderr = Pipe() 58 + process.standardOutput = stdout 59 + process.standardError = stderr 59 60 60 - do { 61 - try process.run() 62 - } catch { 63 - continuation.resume(throwing: CommandError.launchFailed(error.localizedDescription)) 64 - return 65 - } 61 + do { 62 + try process.run() 63 + } catch { 64 + continuation.resume(throwing: CommandError.launchFailed(error.localizedDescription)) 65 + return 66 + } 66 67 67 - process.waitUntilExit() 68 + process.waitUntilExit() 68 69 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) ?? "" 70 + let outData = stdout.fileHandleForReading.readDataToEndOfFile() 71 + let errData = stderr.fileHandleForReading.readDataToEndOfFile() 72 + let outString = String(data: outData, encoding: .utf8) ?? "" 73 + let errString = String(data: errData, encoding: .utf8) ?? "" 73 74 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 - } 75 + if process.terminationStatus != 0 { 76 + continuation.resume( 77 + throwing: CommandError.nonZeroExit( 78 + status: process.terminationStatus, 79 + stderr: errString.trimmingCharacters(in: .whitespacesAndNewlines) 80 + )) 81 + } else { 82 + continuation.resume(returning: outString) 83 + } 84 84 } 85 + } 85 86 86 - enum CommandError: LocalizedError { 87 - case launchFailed(String) 88 - case nonZeroExit(status: Int32, stderr: String) 87 + enum CommandError: LocalizedError { 88 + case launchFailed(String) 89 + case nonZeroExit(status: Int32, stderr: String) 89 90 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 - } 91 + var errorDescription: String? { 92 + switch self { 93 + case .launchFailed(let reason): 94 + return "Failed to launch jj: \(reason)" 95 + case .nonZeroExit(let status, let stderr): 96 + return "jj exited with status \(status): \(stderr)" 97 + } 98 98 } 99 + } 99 100 }
+84 -84
Sources/HomeManagerStatus/RepoStatus.swift
··· 2 2 3 3 /// Represents the current status of the jj repository relative to trunk(). 4 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? 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 13 14 - /// The last time this status was successfully checked. 15 - let checkedAt: Date 14 + /// The last time this status was successfully checked. 15 + let checkedAt: Date 16 16 17 - // MARK: - Convenience initializers 17 + // MARK: - Convenience initializers 18 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 - } 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 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 - } 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 26 27 - // MARK: - Computed properties 27 + // MARK: - Computed properties 28 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 } 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 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" 35 + /// The SF Symbol name to display in the menu bar. 36 + var menuBarIcon: String { 37 + if hasError { 38 + return "exclamationmark.triangle.fill" 50 39 } 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 40 + if isDiverged { 41 + return "arrow.up.arrow.down.circle.fill" 59 42 } 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 "" 43 + if isAhead { 44 + return "arrow.up.circle.fill" 68 45 } 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 46 + if isBehind { 47 + return "arrow.down.circle.fill" 77 48 } 49 + return "checkmark.circle.fill" 50 + } 78 51 79 - /// Human-readable status description for the dropdown. 80 - var statusDescription: String { 81 - if hasError { 82 - return "Error: \(error!)" 83 - } 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 + } 84 60 85 - var parts: [String] = [] 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 + } 86 69 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 - } 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 + } 97 78 98 - if dirty { 99 - parts.append("Working copy has changes") 100 - } else if !hasError { 101 - parts.append("Working copy is clean") 102 - } 79 + /// Human-readable status description for the dropdown. 80 + var statusDescription: String { 81 + if hasError { 82 + return "Error: \(error!)" 83 + } 103 84 104 - return parts.joined(separator: "\n") 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 + } 105 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 106 }
+77 -77
Sources/HomeManagerStatus/StatusChecker.swift
··· 4 4 /// Orchestrates periodic jj status checks and publishes results for the UI. 5 5 @MainActor 6 6 final class StatusChecker: ObservableObject { 7 - @Published private(set) var status: RepoStatus 7 + @Published private(set) var status: RepoStatus 8 8 9 - let repoPath: String 10 - let refreshInterval: TimeInterval 11 - private let runner: JujutsuCommandRunner 12 - private var timer: Timer? 9 + let repoPath: String 10 + let refreshInterval: TimeInterval 11 + private let runner: JujutsuCommandRunner 12 + private var timer: Timer? 13 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 - } 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 28 29 - /// Start periodic status checks. 30 - func startPolling() { 31 - // Run immediately 32 - Task { await refresh() } 29 + /// Start periodic status checks. 30 + func startPolling() { 31 + // Run immediately 32 + Task { await refresh() } 33 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 - } 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 + } 42 41 } 42 + } 43 43 44 - /// Stop periodic status checks. 45 - func stopPolling() { 46 - timer?.invalidate() 47 - timer = nil 48 - } 44 + /// Stop periodic status checks. 45 + func stopPolling() { 46 + timer?.invalidate() 47 + timer = nil 48 + } 49 49 50 - /// Perform a single status check. 51 - func refresh() async { 52 - let now = Date.now 50 + /// Perform a single status check. 51 + func refresh() async { 52 + let now = Date.now 53 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 - ) 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 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 - ) 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 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 - ) 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 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 - } 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) 95 94 } 95 + } 96 96 }
+67 -67
Sources/HomeManagerStatus/StatusParser.swift
··· 2 2 3 3 /// Pure functions for parsing jj command output into RepoStatus. 4 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 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 16 14 } 15 + return trimmed.components(separatedBy: "\n").count 16 + } 17 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 - } 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) 33 32 } 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) 33 + } 51 34 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 - } 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) 61 51 62 - return RepoStatus( 63 - ahead: ahead, 64 - behind: behind, 65 - dirty: dirty, 66 - error: nil, 67 - checkedAt: date 68 - ) 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 + ) 69 60 } 70 61 71 - enum ParserError: LocalizedError { 72 - case unexpectedDirtyOutput(String) 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 73 74 - var errorDescription: String? { 75 - switch self { 76 - case .unexpectedDirtyOutput(let output): 77 - return "Unexpected dirty status output: '\(output)'" 78 - } 79 - } 74 + var errorDescription: String? { 75 + switch self { 76 + case .unexpectedDirtyOutput(let output): 77 + return "Unexpected dirty status output: '\(output)'" 78 + } 80 79 } 80 + } 81 81 }
+176 -176
Tests/HomeManagerStatusTests/RepoStatusTests.swift
··· 4 4 @testable import HomeManagerStatus 5 5 6 6 struct RepoStatusTests { 7 - let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 7 + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 8 9 - // MARK: - Boolean state properties 9 + // MARK: - Boolean state properties 10 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 52 53 - // MARK: - Menu bar icon 53 + // MARK: - Menu bar icon 54 54 55 - @Test func menuBarIconSynced() { 56 - let status = RepoStatus.synced(at: fixedDate) 57 - #expect(status.menuBarIcon == "checkmark.circle.fill") 58 - } 55 + @Test func menuBarIconSynced() { 56 + let status = RepoStatus.synced(at: fixedDate) 57 + #expect(status.menuBarIcon == "checkmark.circle.fill") 58 + } 59 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 - } 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 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 - } 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 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 - } 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 74 75 - @Test func menuBarIconError() { 76 - let status = RepoStatus.error("oops", at: fixedDate) 77 - #expect(status.menuBarIcon == "exclamationmark.triangle.fill") 78 - } 75 + @Test func menuBarIconError() { 76 + let status = RepoStatus.error("oops", at: fixedDate) 77 + #expect(status.menuBarIcon == "exclamationmark.triangle.fill") 78 + } 79 79 80 - // MARK: - Menu bar color 80 + // MARK: - Menu bar color 81 81 82 - @Test func menuBarColorSynced() { 83 - let status = RepoStatus.synced(at: fixedDate) 84 - #expect(status.menuBarColor == .green) 85 - } 82 + @Test func menuBarColorSynced() { 83 + let status = RepoStatus.synced(at: fixedDate) 84 + #expect(status.menuBarColor == .green) 85 + } 86 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 - } 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 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 - } 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 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 - } 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 101 102 - @Test func menuBarColorError() { 103 - let status = RepoStatus.error("oops", at: fixedDate) 104 - #expect(status.menuBarColor == .red) 105 - } 102 + @Test func menuBarColorError() { 103 + let status = RepoStatus.error("oops", at: fixedDate) 104 + #expect(status.menuBarColor == .red) 105 + } 106 106 107 - // MARK: - Menu bar text 107 + // MARK: - Menu bar text 108 108 109 - @Test func menuBarTextSynced() { 110 - let status = RepoStatus.synced(at: fixedDate) 111 - #expect(status.menuBarText == "") 112 - } 109 + @Test func menuBarTextSynced() { 110 + let status = RepoStatus.synced(at: fixedDate) 111 + #expect(status.menuBarText == "") 112 + } 113 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 - } 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 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 - } 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 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 - } 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 128 129 - @Test func menuBarTextError() { 130 - let status = RepoStatus.error("oops", at: fixedDate) 131 - #expect(status.menuBarText == "!") 132 - } 129 + @Test func menuBarTextError() { 130 + let status = RepoStatus.error("oops", at: fixedDate) 131 + #expect(status.menuBarText == "!") 132 + } 133 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 - } 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 139 140 - // MARK: - Menu bar label (text + dirty dot) 140 + // MARK: - Menu bar label (text + dirty dot) 141 141 142 - @Test func menuBarLabelSyncedClean() { 143 - let status = RepoStatus.synced(dirty: false, at: fixedDate) 144 - #expect(status.menuBarLabel == "") 145 - } 142 + @Test func menuBarLabelSyncedClean() { 143 + let status = RepoStatus.synced(dirty: false, at: fixedDate) 144 + #expect(status.menuBarLabel == "") 145 + } 146 146 147 - @Test func menuBarLabelSyncedDirty() { 148 - let status = RepoStatus.synced(dirty: true, at: fixedDate) 149 - #expect(status.menuBarLabel == "\u{25CF}") 150 - } 147 + @Test func menuBarLabelSyncedDirty() { 148 + let status = RepoStatus.synced(dirty: true, at: fixedDate) 149 + #expect(status.menuBarLabel == "\u{25CF}") 150 + } 151 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 - } 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 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 - } 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 163 164 - // MARK: - Status description 164 + // MARK: - Status description 165 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 207 208 - // MARK: - Convenience initializers 208 + // MARK: - Convenience initializers 209 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 - } 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 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 - } 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 225 }
+117 -117
Tests/HomeManagerStatusTests/StatusCheckerTests.swift
··· 7 7 8 8 /// A mock JujutsuCommandRunner that returns predefined responses based on arguments. 9 9 struct MockJujutsuCommandRunner: JujutsuCommandRunner, Sendable { 10 - let responses: @Sendable ([String]) -> Result<String, Error> 10 + let responses: @Sendable ([String]) -> Result<String, Error> 11 11 12 - init(responses: @escaping @Sendable ([String]) -> Result<String, Error>) { 13 - self.responses = responses 14 - } 12 + init(responses: @escaping @Sendable ([String]) -> Result<String, Error>) { 13 + self.responses = responses 14 + } 15 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)) 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) 33 30 } 31 + } 32 + return .failure(MockError.unexpectedArgs(args)) 34 33 } 34 + } 35 35 36 - /// Create a mock that always fails. 37 - static func failing(error: String) -> MockJujutsuCommandRunner { 38 - MockJujutsuCommandRunner { _ in 39 - .failure(MockError.simulated(error)) 40 - } 36 + /// Create a mock that always fails. 37 + static func failing(error: String) -> MockJujutsuCommandRunner { 38 + MockJujutsuCommandRunner { _ in 39 + .failure(MockError.simulated(error)) 41 40 } 41 + } 42 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 - } 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 50 49 } 50 + } 51 51 52 - enum MockError: LocalizedError { 53 - case unexpectedArgs([String]) 54 - case simulated(String) 52 + enum MockError: LocalizedError { 53 + case unexpectedArgs([String]) 54 + case simulated(String) 55 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 - } 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 + } 64 63 } 64 + } 65 65 } 66 66 67 67 // MARK: - StatusChecker Tests 68 68 69 69 @Suite(.serialized) 70 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) 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 74 75 - await checker.refresh() 75 + await checker.refresh() 76 76 77 - #expect(checker.status.isSynced) 78 - #expect(!checker.status.dirty) 79 - #expect(checker.status.error == nil) 80 - } 77 + #expect(checker.status.isSynced) 78 + #expect(!checker.status.dirty) 79 + #expect(checker.status.error == nil) 80 + } 81 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) 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 86 87 - await checker.refresh() 87 + await checker.refresh() 88 88 89 - #expect(checker.status.ahead == 2) 90 - #expect(checker.status.behind == 0) 91 - #expect(checker.status.isAhead) 92 - } 89 + #expect(checker.status.ahead == 2) 90 + #expect(checker.status.behind == 0) 91 + #expect(checker.status.isAhead) 92 + } 93 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) 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 98 99 - await checker.refresh() 99 + await checker.refresh() 100 100 101 - #expect(checker.status.ahead == 0) 102 - #expect(checker.status.behind == 3) 103 - #expect(checker.status.isBehind) 104 - } 101 + #expect(checker.status.ahead == 0) 102 + #expect(checker.status.behind == 3) 103 + #expect(checker.status.isBehind) 104 + } 105 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) 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 110 111 - await checker.refresh() 111 + await checker.refresh() 112 112 113 - #expect(checker.status.ahead == 1) 114 - #expect(checker.status.behind == 2) 115 - #expect(checker.status.dirty) 116 - #expect(checker.status.isDiverged) 117 - } 113 + #expect(checker.status.ahead == 1) 114 + #expect(checker.status.behind == 2) 115 + #expect(checker.status.dirty) 116 + #expect(checker.status.isDiverged) 117 + } 118 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) 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 122 123 - await checker.refresh() 123 + await checker.refresh() 124 124 125 - #expect(checker.status.isSynced) 126 - #expect(checker.status.dirty) 127 - } 125 + #expect(checker.status.isSynced) 126 + #expect(checker.status.dirty) 127 + } 128 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) 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 132 133 - await checker.refresh() 133 + await checker.refresh() 134 134 135 - #expect(checker.status.hasError) 136 - #expect(checker.status.error?.contains("jj: command not found") == true) 137 - } 135 + #expect(checker.status.hasError) 136 + #expect(checker.status.error?.contains("jj: command not found") == true) 137 + } 138 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) 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 142 143 - #expect(checker.status.hasError) 144 - #expect(checker.status.error == "Not yet checked") 145 - } 143 + #expect(checker.status.hasError) 144 + #expect(checker.status.error == "Not yet checked") 145 + } 146 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) 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 151 152 - #expect(checker.repoPath == "/home/user/.config/home-manager") 153 - } 152 + #expect(checker.repoPath == "/home/user/.config/home-manager") 153 + } 154 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) 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 159 160 - // Initial state is error (not yet checked) 161 - #expect(checker.status.hasError) 160 + // Initial state is error (not yet checked) 161 + #expect(checker.status.hasError) 162 162 163 - // After refresh, should be behind 164 - await checker.refresh() 165 - #expect(checker.status.isBehind) 166 - #expect(checker.status.behind == 1) 167 - } 163 + // After refresh, should be behind 164 + await checker.refresh() 165 + #expect(checker.status.isBehind) 166 + #expect(checker.status.behind == 1) 167 + } 168 168 }
+130 -130
Tests/HomeManagerStatusTests/StatusParserTests.swift
··· 4 4 @testable import HomeManagerStatus 5 5 6 6 struct StatusParserTests { 7 - let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 7 + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 8 9 - // MARK: - parseCommitCount 9 + // MARK: - parseCommitCount 10 10 11 - @Test func parseCommitCountEmpty() { 12 - #expect(StatusParser.parseCommitCount(from: "") == 0) 13 - } 11 + @Test func parseCommitCountEmpty() { 12 + #expect(StatusParser.parseCommitCount(from: "") == 0) 13 + } 14 14 15 - @Test func parseCommitCountWhitespaceOnly() { 16 - #expect(StatusParser.parseCommitCount(from: " \n \n ") == 0) 17 - } 15 + @Test func parseCommitCountWhitespaceOnly() { 16 + #expect(StatusParser.parseCommitCount(from: " \n \n ") == 0) 17 + } 18 18 19 - @Test func parseCommitCountSingleCommit() { 20 - #expect(StatusParser.parseCommitCount(from: "abc123\n") == 1) 21 - } 19 + @Test func parseCommitCountSingleCommit() { 20 + #expect(StatusParser.parseCommitCount(from: "abc123\n") == 1) 21 + } 22 22 23 - @Test func parseCommitCountSingleCommitNoNewline() { 24 - #expect(StatusParser.parseCommitCount(from: "abc123") == 1) 25 - } 23 + @Test func parseCommitCountSingleCommitNoNewline() { 24 + #expect(StatusParser.parseCommitCount(from: "abc123") == 1) 25 + } 26 26 27 - @Test func parseCommitCountMultipleCommits() { 28 - let output = "abc123\ndef456\nghi789\n" 29 - #expect(StatusParser.parseCommitCount(from: output) == 3) 30 - } 27 + @Test func parseCommitCountMultipleCommits() { 28 + let output = "abc123\ndef456\nghi789\n" 29 + #expect(StatusParser.parseCommitCount(from: output) == 3) 30 + } 31 31 32 - @Test func parseCommitCountMultipleCommitsNoTrailingNewline() { 33 - let output = "abc123\ndef456\nghi789" 34 - #expect(StatusParser.parseCommitCount(from: output) == 3) 35 - } 32 + @Test func parseCommitCountMultipleCommitsNoTrailingNewline() { 33 + let output = "abc123\ndef456\nghi789" 34 + #expect(StatusParser.parseCommitCount(from: output) == 3) 35 + } 36 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 - } 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 42 43 - // MARK: - parseDirtyStatus 43 + // MARK: - parseDirtyStatus 44 44 45 - @Test func parseDirtyStatusClean() throws { 46 - let result = try StatusParser.parseDirtyStatus(from: "clean") 47 - #expect(result == false) 48 - } 45 + @Test func parseDirtyStatusClean() throws { 46 + let result = try StatusParser.parseDirtyStatus(from: "clean") 47 + #expect(result == false) 48 + } 49 49 50 - @Test func parseDirtyStatusDirty() throws { 51 - let result = try StatusParser.parseDirtyStatus(from: "dirty") 52 - #expect(result == true) 53 - } 50 + @Test func parseDirtyStatusDirty() throws { 51 + let result = try StatusParser.parseDirtyStatus(from: "dirty") 52 + #expect(result == true) 53 + } 54 54 55 - @Test func parseDirtyStatusCleanWithWhitespace() throws { 56 - let result = try StatusParser.parseDirtyStatus(from: " clean \n") 57 - #expect(result == false) 58 - } 55 + @Test func parseDirtyStatusCleanWithWhitespace() throws { 56 + let result = try StatusParser.parseDirtyStatus(from: " clean \n") 57 + #expect(result == false) 58 + } 59 59 60 - @Test func parseDirtyStatusDirtyWithWhitespace() throws { 61 - let result = try StatusParser.parseDirtyStatus(from: "\ndirty\n") 62 - #expect(result == true) 63 - } 60 + @Test func parseDirtyStatusDirtyWithWhitespace() throws { 61 + let result = try StatusParser.parseDirtyStatus(from: "\ndirty\n") 62 + #expect(result == true) 63 + } 64 64 65 - @Test func parseDirtyStatusUnexpectedOutput() { 66 - #expect(throws: StatusParser.ParserError.self) { 67 - try StatusParser.parseDirtyStatus(from: "unknown") 68 - } 65 + @Test func parseDirtyStatusUnexpectedOutput() { 66 + #expect(throws: StatusParser.ParserError.self) { 67 + try StatusParser.parseDirtyStatus(from: "unknown") 69 68 } 69 + } 70 70 71 - @Test func parseDirtyStatusEmptyOutput() { 72 - #expect(throws: StatusParser.ParserError.self) { 73 - try StatusParser.parseDirtyStatus(from: "") 74 - } 71 + @Test func parseDirtyStatusEmptyOutput() { 72 + #expect(throws: StatusParser.ParserError.self) { 73 + try StatusParser.parseDirtyStatus(from: "") 75 74 } 75 + } 76 76 77 - // MARK: - parse (combined) 77 + // MARK: - parse (combined) 78 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 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 - } 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 162 }
+82
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-parts": { 4 + "inputs": { 5 + "nixpkgs-lib": "nixpkgs-lib" 6 + }, 7 + "locked": { 8 + "lastModified": 1772408722, 9 + "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", 10 + "owner": "hercules-ci", 11 + "repo": "flake-parts", 12 + "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "hercules-ci", 17 + "repo": "flake-parts", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1772479524, 24 + "narHash": "sha256-u7nCaNiMjqvKpE+uZz9hE7pgXXTmm5yvdtFaqzSzUQI=", 25 + "owner": "NixOS", 26 + "repo": "nixpkgs", 27 + "rev": "4215e62dc2cd3bc705b0a423b9719ff6be378a43", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "NixOS", 32 + "ref": "nixpkgs-unstable", 33 + "repo": "nixpkgs", 34 + "type": "github" 35 + } 36 + }, 37 + "nixpkgs-lib": { 38 + "locked": { 39 + "lastModified": 1772328832, 40 + "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", 41 + "owner": "nix-community", 42 + "repo": "nixpkgs.lib", 43 + "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", 44 + "type": "github" 45 + }, 46 + "original": { 47 + "owner": "nix-community", 48 + "repo": "nixpkgs.lib", 49 + "type": "github" 50 + } 51 + }, 52 + "root": { 53 + "inputs": { 54 + "flake-parts": "flake-parts", 55 + "nixpkgs": "nixpkgs", 56 + "treefmt-nix": "treefmt-nix" 57 + } 58 + }, 59 + "treefmt-nix": { 60 + "inputs": { 61 + "nixpkgs": [ 62 + "nixpkgs" 63 + ] 64 + }, 65 + "locked": { 66 + "lastModified": 1770228511, 67 + "narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=", 68 + "owner": "numtide", 69 + "repo": "treefmt-nix", 70 + "rev": "337a4fe074be1042a35086f15481d763b8ddc0e7", 71 + "type": "github" 72 + }, 73 + "original": { 74 + "owner": "numtide", 75 + "repo": "treefmt-nix", 76 + "type": "github" 77 + } 78 + } 79 + }, 80 + "root": "root", 81 + "version": 7 82 + }
+44
flake.nix
··· 1 + { 2 + description = "HomeManagerStatus — macOS menu bar app for jj repo status"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 + flake-parts.url = "github:hercules-ci/flake-parts"; 7 + treefmt-nix = { 8 + url = "github:numtide/treefmt-nix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + }; 12 + 13 + outputs = 14 + inputs: 15 + inputs.flake-parts.lib.mkFlake { inherit inputs; } { 16 + systems = [ 17 + "aarch64-darwin" 18 + "x86_64-darwin" 19 + ]; 20 + 21 + imports = [ 22 + inputs.treefmt-nix.flakeModule 23 + ]; 24 + 25 + perSystem = 26 + { pkgs, ... }: 27 + { 28 + treefmt = { 29 + projectRootFile = "flake.nix"; 30 + programs = { 31 + nixfmt.enable = true; 32 + shfmt.enable = true; 33 + swift-format.enable = true; 34 + }; 35 + }; 36 + 37 + devShells.default = pkgs.mkShell { 38 + packages = with pkgs; [ 39 + swift-format 40 + ]; 41 + }; 42 + }; 43 + }; 44 + }