A macOS utility to track home-manager JJ repo status

Downgrade to Swift 5.9/5.10, add nix derivation

+283 -240
+1
.gitignore
··· 5 5 *.xcworkspace/ 6 6 DerivedData/ 7 7 *.app/ 8 + result
+24
Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleExecutable</key> 6 + <string>HomeManagerStatus</string> 7 + <key>CFBundleIdentifier</key> 8 + <string>dev.nolith.HomeManagerStatus</string> 9 + <key>CFBundleName</key> 10 + <string>HomeManagerStatus</string> 11 + <key>CFBundleDisplayName</key> 12 + <string>Home Manager Status</string> 13 + <key>CFBundleVersion</key> 14 + <string>1</string> 15 + <key>CFBundleShortVersionString</key> 16 + <string>1.0.0</string> 17 + <key>CFBundlePackageType</key> 18 + <string>APPL</string> 19 + <key>LSMinimumSystemVersion</key> 20 + <string>13.0</string> 21 + <key>LSUIElement</key> 22 + <true/> 23 + </dict> 24 + </plist>
+3 -9
Package.swift
··· 1 - // swift-tools-version: 6.0 1 + // swift-tools-version: 5.9 2 2 3 3 import PackageDescription 4 4 5 5 let package = Package( 6 6 name: "HomeManagerStatus", 7 7 platforms: [ 8 - .macOS(.v14) 9 - ], 10 - dependencies: [ 11 - .package(url: "https://github.com/swiftlang/swift-testing.git", branch: "release/6.2") 8 + .macOS(.v13) 12 9 ], 13 10 targets: [ 14 11 .executableTarget( ··· 17 14 ), 18 15 .testTarget( 19 16 name: "HomeManagerStatusTests", 20 - dependencies: [ 21 - "HomeManagerStatus", 22 - .product(name: "Testing", package: "swift-testing"), 23 - ], 17 + dependencies: ["HomeManagerStatus"], 24 18 path: "Tests/HomeManagerStatusTests" 25 19 ), 26 20 ]
+1 -1
Sources/HomeManagerStatus/JujutsuCommandRunner.swift
··· 1 1 import Foundation 2 2 3 3 /// Protocol for running jj commands. Abstracted for testability. 4 - protocol JujutsuCommandRunner: Sendable { 4 + protocol JujutsuCommandRunner { 5 5 /// Run a jj command with the given arguments against a repository. 6 6 /// 7 7 /// - Parameters:
+3 -3
Sources/HomeManagerStatus/RepoStatus.swift
··· 1 1 import SwiftUI 2 2 3 3 /// Represents the current status of the jj repository relative to trunk(). 4 - struct RepoStatus: Equatable, Sendable { 4 + struct RepoStatus: Equatable { 5 5 /// Number of commits on the current working copy ancestry that are not on trunk(). 6 6 let ahead: Int 7 7 /// Number of commits on trunk() that are not on the current working copy ancestry. ··· 16 16 17 17 // MARK: - Convenience initializers 18 18 19 - static func synced(dirty: Bool = false, at date: Date = .now) -> RepoStatus { 19 + static func synced(dirty: Bool = false, at date: Date = Date()) -> RepoStatus { 20 20 RepoStatus(ahead: 0, behind: 0, dirty: dirty, error: nil, checkedAt: date) 21 21 } 22 22 23 - static func error(_ message: String, at date: Date = .now) -> RepoStatus { 23 + static func error(_ message: String, at date: Date = Date()) -> RepoStatus { 24 24 RepoStatus(ahead: 0, behind: 0, dirty: false, error: message, checkedAt: date) 25 25 } 26 26
+3 -3
Sources/HomeManagerStatus/StatusChecker.swift
··· 22 22 self.status = RepoStatus( 23 23 ahead: 0, behind: 0, dirty: false, 24 24 error: "Not yet checked", 25 - checkedAt: .now 25 + checkedAt: Date() 26 26 ) 27 27 } 28 28 ··· 34 34 // Then schedule periodic checks 35 35 timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { 36 36 [weak self] _ in 37 - guard let self else { return } 37 + guard let self = self else { return } 38 38 Task { @MainActor in 39 39 await self.refresh() 40 40 } ··· 49 49 50 50 /// Perform a single status check. 51 51 func refresh() async { 52 - let now = Date.now 52 + let now = Date() 53 53 54 54 do { 55 55 async let aheadOutput = runner.run(
+1 -1
Sources/HomeManagerStatus/StatusParser.swift
··· 44 44 aheadOutput: String, 45 45 behindOutput: String, 46 46 dirtyOutput: String, 47 - at date: Date = .now 47 + at date: Date = Date() 48 48 ) -> RepoStatus { 49 49 let ahead = parseCommitCount(from: aheadOutput) 50 50 let behind = parseCommitCount(from: behindOutput)
+97 -98
Tests/HomeManagerStatusTests/RepoStatusTests.swift
··· 1 1 import SwiftUI 2 - import Testing 2 + import XCTest 3 3 4 4 @testable import HomeManagerStatus 5 5 6 - struct RepoStatusTests { 6 + final class RepoStatusTests: XCTestCase { 7 7 let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 8 9 9 // MARK: - Boolean state properties 10 10 11 - @Test func syncedStatus() { 11 + func testSyncedStatus() { 12 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) 13 + XCTAssertTrue(status.isSynced) 14 + XCTAssertFalse(status.isAhead) 15 + XCTAssertFalse(status.isBehind) 16 + XCTAssertFalse(status.isDiverged) 17 + XCTAssertFalse(status.hasError) 18 18 } 19 19 20 - @Test func aheadOnly() { 20 + func testAheadOnly() { 21 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) 22 + XCTAssertFalse(status.isSynced) 23 + XCTAssertTrue(status.isAhead) 24 + XCTAssertFalse(status.isBehind) 25 + XCTAssertFalse(status.isDiverged) 26 26 } 27 27 28 - @Test func behindOnly() { 28 + func testBehindOnly() { 29 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) 30 + XCTAssertFalse(status.isSynced) 31 + XCTAssertFalse(status.isAhead) 32 + XCTAssertTrue(status.isBehind) 33 + XCTAssertFalse(status.isDiverged) 34 34 } 35 35 36 - @Test func diverged() { 36 + func testDiverged() { 37 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) 38 + XCTAssertFalse(status.isSynced) 39 + XCTAssertTrue(status.isAhead) 40 + XCTAssertTrue(status.isBehind) 41 + XCTAssertTrue(status.isDiverged) 42 42 } 43 43 44 - @Test func errorState() { 44 + func testErrorState() { 45 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) 46 + XCTAssertTrue(status.hasError) 47 + XCTAssertFalse(status.isSynced) 48 + XCTAssertFalse(status.isAhead) 49 + XCTAssertFalse(status.isBehind) 50 + XCTAssertFalse(status.isDiverged) 51 51 } 52 52 53 53 // MARK: - Menu bar icon 54 54 55 - @Test func menuBarIconSynced() { 55 + func testMenuBarIconSynced() { 56 56 let status = RepoStatus.synced(at: fixedDate) 57 - #expect(status.menuBarIcon == "checkmark.circle.fill") 57 + XCTAssertEqual(status.menuBarIcon, "checkmark.circle.fill") 58 58 } 59 59 60 - @Test func menuBarIconAhead() { 60 + func testMenuBarIconAhead() { 61 61 let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 62 - #expect(status.menuBarIcon == "arrow.up.circle.fill") 62 + XCTAssertEqual(status.menuBarIcon, "arrow.up.circle.fill") 63 63 } 64 64 65 - @Test func menuBarIconBehind() { 65 + func testMenuBarIconBehind() { 66 66 let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 67 - #expect(status.menuBarIcon == "arrow.down.circle.fill") 67 + XCTAssertEqual(status.menuBarIcon, "arrow.down.circle.fill") 68 68 } 69 69 70 - @Test func menuBarIconDiverged() { 70 + func testMenuBarIconDiverged() { 71 71 let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 72 - #expect(status.menuBarIcon == "arrow.up.arrow.down.circle.fill") 72 + XCTAssertEqual(status.menuBarIcon, "arrow.up.arrow.down.circle.fill") 73 73 } 74 74 75 - @Test func menuBarIconError() { 75 + func testMenuBarIconError() { 76 76 let status = RepoStatus.error("oops", at: fixedDate) 77 - #expect(status.menuBarIcon == "exclamationmark.triangle.fill") 77 + XCTAssertEqual(status.menuBarIcon, "exclamationmark.triangle.fill") 78 78 } 79 79 80 80 // MARK: - Menu bar color 81 81 82 - @Test func menuBarColorSynced() { 82 + func testMenuBarColorSynced() { 83 83 let status = RepoStatus.synced(at: fixedDate) 84 - #expect(status.menuBarColor == .green) 84 + XCTAssertEqual(status.menuBarColor, .green) 85 85 } 86 86 87 - @Test func menuBarColorAhead() { 87 + func testMenuBarColorAhead() { 88 88 let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 89 - #expect(status.menuBarColor == .blue) 89 + XCTAssertEqual(status.menuBarColor, .blue) 90 90 } 91 91 92 - @Test func menuBarColorBehind() { 92 + func testMenuBarColorBehind() { 93 93 let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 94 - #expect(status.menuBarColor == .orange) 94 + XCTAssertEqual(status.menuBarColor, .orange) 95 95 } 96 96 97 - @Test func menuBarColorDiverged() { 97 + func testMenuBarColorDiverged() { 98 98 let status = RepoStatus(ahead: 1, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 99 - #expect(status.menuBarColor == .yellow) 99 + XCTAssertEqual(status.menuBarColor, .yellow) 100 100 } 101 101 102 - @Test func menuBarColorError() { 102 + func testMenuBarColorError() { 103 103 let status = RepoStatus.error("oops", at: fixedDate) 104 - #expect(status.menuBarColor == .red) 104 + XCTAssertEqual(status.menuBarColor, .red) 105 105 } 106 106 107 107 // MARK: - Menu bar text 108 108 109 - @Test func menuBarTextSynced() { 109 + func testMenuBarTextSynced() { 110 110 let status = RepoStatus.synced(at: fixedDate) 111 - #expect(status.menuBarText == "") 111 + XCTAssertEqual(status.menuBarText, "") 112 112 } 113 113 114 - @Test func menuBarTextAhead() { 114 + func testMenuBarTextAhead() { 115 115 let status = RepoStatus(ahead: 2, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 116 - #expect(status.menuBarText == "\u{2191}2") 116 + XCTAssertEqual(status.menuBarText, "\u{2191}2") 117 117 } 118 118 119 - @Test func menuBarTextBehind() { 119 + func testMenuBarTextBehind() { 120 120 let status = RepoStatus(ahead: 0, behind: 7, dirty: false, error: nil, checkedAt: fixedDate) 121 - #expect(status.menuBarText == "\u{2193}7") 121 + XCTAssertEqual(status.menuBarText, "\u{2193}7") 122 122 } 123 123 124 - @Test func menuBarTextDiverged() { 124 + func testMenuBarTextDiverged() { 125 125 let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 126 - #expect(status.menuBarText == "\u{2191}2\u{2193}3") 126 + XCTAssertEqual(status.menuBarText, "\u{2191}2\u{2193}3") 127 127 } 128 128 129 - @Test func menuBarTextError() { 129 + func testMenuBarTextError() { 130 130 let status = RepoStatus.error("oops", at: fixedDate) 131 - #expect(status.menuBarText == "!") 131 + XCTAssertEqual(status.menuBarText, "!") 132 132 } 133 133 134 - @Test func menuBarTextLargeNumbers() { 134 + func testMenuBarTextLargeNumbers() { 135 135 let status = RepoStatus( 136 136 ahead: 42, behind: 100, dirty: false, error: nil, checkedAt: fixedDate) 137 - #expect(status.menuBarText == "\u{2191}42\u{2193}100") 137 + XCTAssertEqual(status.menuBarText, "\u{2191}42\u{2193}100") 138 138 } 139 139 140 140 // MARK: - Menu bar label (text + dirty dot) 141 141 142 - @Test func menuBarLabelSyncedClean() { 142 + func testMenuBarLabelSyncedClean() { 143 143 let status = RepoStatus.synced(dirty: false, at: fixedDate) 144 - #expect(status.menuBarLabel == "") 144 + XCTAssertEqual(status.menuBarLabel, "") 145 145 } 146 146 147 - @Test func menuBarLabelSyncedDirty() { 147 + func testMenuBarLabelSyncedDirty() { 148 148 let status = RepoStatus.synced(dirty: true, at: fixedDate) 149 - #expect(status.menuBarLabel == "\u{25CF}") 149 + XCTAssertEqual(status.menuBarLabel, "\u{25CF}") 150 150 } 151 151 152 - @Test func menuBarLabelAheadDirty() { 152 + func testMenuBarLabelAheadDirty() { 153 153 let status = RepoStatus(ahead: 2, behind: 0, dirty: true, error: nil, checkedAt: fixedDate) 154 - #expect(status.menuBarLabel == "\u{2191}2\u{25CF}") 154 + XCTAssertEqual(status.menuBarLabel, "\u{2191}2\u{25CF}") 155 155 } 156 156 157 - @Test func menuBarLabelErrorNoDot() { 157 + func testMenuBarLabelErrorNoDot() { 158 158 let status = RepoStatus( 159 159 ahead: 0, behind: 0, dirty: true, error: "err", checkedAt: fixedDate) 160 - // Error state should not show dirty dot 161 - #expect(status.menuBarLabel == "!") 160 + XCTAssertEqual(status.menuBarLabel, "!") 162 161 } 163 162 164 163 // MARK: - Status description 165 164 166 - @Test func statusDescriptionSynced() { 165 + func testStatusDescriptionSynced() { 167 166 let status = RepoStatus.synced(at: fixedDate) 168 - #expect(status.statusDescription.contains("In sync with trunk")) 169 - #expect(status.statusDescription.contains("Working copy is clean")) 167 + XCTAssertTrue(status.statusDescription.contains("In sync with trunk")) 168 + XCTAssertTrue(status.statusDescription.contains("Working copy is clean")) 170 169 } 171 170 172 - @Test func statusDescriptionAhead() { 171 + func testStatusDescriptionAhead() { 173 172 let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 174 - #expect(status.statusDescription.contains("1 commit ahead of trunk")) 173 + XCTAssertTrue(status.statusDescription.contains("1 commit ahead of trunk")) 175 174 } 176 175 177 - @Test func statusDescriptionAheadPlural() { 176 + func testStatusDescriptionAheadPlural() { 178 177 let status = RepoStatus(ahead: 5, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 179 - #expect(status.statusDescription.contains("5 commits ahead of trunk")) 178 + XCTAssertTrue(status.statusDescription.contains("5 commits ahead of trunk")) 180 179 } 181 180 182 - @Test func statusDescriptionBehind() { 181 + func testStatusDescriptionBehind() { 183 182 let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 184 - #expect(status.statusDescription.contains("1 commit behind trunk")) 183 + XCTAssertTrue(status.statusDescription.contains("1 commit behind trunk")) 185 184 } 186 185 187 - @Test func statusDescriptionBehindPlural() { 186 + func testStatusDescriptionBehindPlural() { 188 187 let status = RepoStatus(ahead: 0, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 189 - #expect(status.statusDescription.contains("3 commits behind trunk")) 188 + XCTAssertTrue(status.statusDescription.contains("3 commits behind trunk")) 190 189 } 191 190 192 - @Test func statusDescriptionDiverged() { 191 + func testStatusDescriptionDiverged() { 193 192 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")) 193 + XCTAssertTrue(status.statusDescription.contains("2 commits ahead of trunk")) 194 + XCTAssertTrue(status.statusDescription.contains("3 commits behind trunk")) 196 195 } 197 196 198 - @Test func statusDescriptionDirty() { 197 + func testStatusDescriptionDirty() { 199 198 let status = RepoStatus(ahead: 0, behind: 0, dirty: true, error: nil, checkedAt: fixedDate) 200 - #expect(status.statusDescription.contains("Working copy has changes")) 199 + XCTAssertTrue(status.statusDescription.contains("Working copy has changes")) 201 200 } 202 201 203 - @Test func statusDescriptionError() { 202 + func testStatusDescriptionError() { 204 203 let status = RepoStatus.error("jj not found", at: fixedDate) 205 - #expect(status.statusDescription.contains("Error: jj not found")) 204 + XCTAssertTrue(status.statusDescription.contains("Error: jj not found")) 206 205 } 207 206 208 207 // MARK: - Convenience initializers 209 208 210 - @Test func syncedConvenience() { 209 + func testSyncedConvenience() { 211 210 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) 211 + XCTAssertEqual(status.ahead, 0) 212 + XCTAssertEqual(status.behind, 0) 213 + XCTAssertTrue(status.dirty) 214 + XCTAssertNil(status.error) 216 215 } 217 216 218 - @Test func errorConvenience() { 217 + func testErrorConvenience() { 219 218 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") 219 + XCTAssertEqual(status.ahead, 0) 220 + XCTAssertEqual(status.behind, 0) 221 + XCTAssertFalse(status.dirty) 222 + XCTAssertEqual(status.error, "boom") 224 223 } 225 224 }
+39 -38
Tests/HomeManagerStatusTests/StatusCheckerTests.swift
··· 1 1 import Foundation 2 - import Testing 2 + import XCTest 3 3 4 4 @testable import HomeManagerStatus 5 5 6 6 // MARK: - Mock Command Runner 7 7 8 8 /// A mock JujutsuCommandRunner that returns predefined responses based on arguments. 9 - struct MockJujutsuCommandRunner: JujutsuCommandRunner, Sendable { 10 - let responses: @Sendable ([String]) -> Result<String, Error> 9 + struct MockJujutsuCommandRunner: JujutsuCommandRunner { 10 + let responses: ([String]) -> Result<String, Error> 11 11 12 - init(responses: @escaping @Sendable ([String]) -> Result<String, Error>) { 12 + init(responses: @escaping ([String]) -> Result<String, Error>) { 13 13 self.responses = responses 14 14 } 15 15 ··· 66 66 67 67 // MARK: - StatusChecker Tests 68 68 69 - @Suite(.serialized) 70 - struct StatusCheckerTests { 71 - @Test @MainActor func refreshSynced() async { 69 + @MainActor 70 + final class StatusCheckerTests: XCTestCase { 71 + 72 + func testRefreshSynced() async { 72 73 let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 73 74 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 74 75 75 76 await checker.refresh() 76 77 77 - #expect(checker.status.isSynced) 78 - #expect(!checker.status.dirty) 79 - #expect(checker.status.error == nil) 78 + XCTAssertTrue(checker.status.isSynced) 79 + XCTAssertFalse(checker.status.dirty) 80 + XCTAssertNil(checker.status.error) 80 81 } 81 82 82 - @Test @MainActor func refreshAhead() async { 83 + func testRefreshAhead() async { 83 84 let runner = MockJujutsuCommandRunner.fixed( 84 85 ahead: "abc123\ndef456\n", behind: "", dirty: "clean") 85 86 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 86 87 87 88 await checker.refresh() 88 89 89 - #expect(checker.status.ahead == 2) 90 - #expect(checker.status.behind == 0) 91 - #expect(checker.status.isAhead) 90 + XCTAssertEqual(checker.status.ahead, 2) 91 + XCTAssertEqual(checker.status.behind, 0) 92 + XCTAssertTrue(checker.status.isAhead) 92 93 } 93 94 94 - @Test @MainActor func refreshBehind() async { 95 + func testRefreshBehind() async { 95 96 let runner = MockJujutsuCommandRunner.fixed( 96 97 ahead: "", behind: "abc123\ndef456\nghi789\n", dirty: "clean") 97 98 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 98 99 99 100 await checker.refresh() 100 101 101 - #expect(checker.status.ahead == 0) 102 - #expect(checker.status.behind == 3) 103 - #expect(checker.status.isBehind) 102 + XCTAssertEqual(checker.status.ahead, 0) 103 + XCTAssertEqual(checker.status.behind, 3) 104 + XCTAssertTrue(checker.status.isBehind) 104 105 } 105 106 106 - @Test @MainActor func refreshDiverged() async { 107 + func testRefreshDiverged() async { 107 108 let runner = MockJujutsuCommandRunner.fixed( 108 109 ahead: "abc123\n", behind: "def456\nghi789\n", dirty: "dirty") 109 110 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 110 111 111 112 await checker.refresh() 112 113 113 - #expect(checker.status.ahead == 1) 114 - #expect(checker.status.behind == 2) 115 - #expect(checker.status.dirty) 116 - #expect(checker.status.isDiverged) 114 + XCTAssertEqual(checker.status.ahead, 1) 115 + XCTAssertEqual(checker.status.behind, 2) 116 + XCTAssertTrue(checker.status.dirty) 117 + XCTAssertTrue(checker.status.isDiverged) 117 118 } 118 119 119 - @Test @MainActor func refreshDirtyOnly() async { 120 + func testRefreshDirtyOnly() async { 120 121 let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "dirty") 121 122 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 122 123 123 124 await checker.refresh() 124 125 125 - #expect(checker.status.isSynced) 126 - #expect(checker.status.dirty) 126 + XCTAssertTrue(checker.status.isSynced) 127 + XCTAssertTrue(checker.status.dirty) 127 128 } 128 129 129 - @Test @MainActor func refreshWithError() async { 130 + func testRefreshWithError() async { 130 131 let runner = MockJujutsuCommandRunner.failing(error: "jj: command not found") 131 132 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 132 133 133 134 await checker.refresh() 134 135 135 - #expect(checker.status.hasError) 136 - #expect(checker.status.error?.contains("jj: command not found") == true) 136 + XCTAssertTrue(checker.status.hasError) 137 + XCTAssertTrue(checker.status.error?.contains("jj: command not found") == true) 137 138 } 138 139 139 - @Test @MainActor func initialStatusIsNotYetChecked() { 140 + func testInitialStatusIsNotYetChecked() { 140 141 let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 141 142 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 142 143 143 - #expect(checker.status.hasError) 144 - #expect(checker.status.error == "Not yet checked") 144 + XCTAssertTrue(checker.status.hasError) 145 + XCTAssertEqual(checker.status.error, "Not yet checked") 145 146 } 146 147 147 - @Test @MainActor func repoPathIsPreserved() { 148 + func testRepoPathIsPreserved() { 148 149 let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 149 150 let checker = StatusChecker( 150 151 repoPath: "/home/user/.config/home-manager", refreshInterval: 300, runner: runner) 151 152 152 - #expect(checker.repoPath == "/home/user/.config/home-manager") 153 + XCTAssertEqual(checker.repoPath, "/home/user/.config/home-manager") 153 154 } 154 155 155 - @Test @MainActor func refreshUpdatesStatus() async { 156 + func testRefreshUpdatesStatus() async { 156 157 let runner = MockJujutsuCommandRunner.fixed( 157 158 ahead: "", behind: "abc123\n", dirty: "clean") 158 159 let checker = StatusChecker(repoPath: "/tmp/test", refreshInterval: 300, runner: runner) 159 160 160 161 // Initial state is error (not yet checked) 161 - #expect(checker.status.hasError) 162 + XCTAssertTrue(checker.status.hasError) 162 163 163 164 // After refresh, should be behind 164 165 await checker.refresh() 165 - #expect(checker.status.isBehind) 166 - #expect(checker.status.behind == 1) 166 + XCTAssertTrue(checker.status.isBehind) 167 + XCTAssertEqual(checker.status.behind, 1) 167 168 } 168 169 }
+56 -60
Tests/HomeManagerStatusTests/StatusParserTests.swift
··· 1 1 import Foundation 2 - import Testing 2 + import XCTest 3 3 4 4 @testable import HomeManagerStatus 5 5 6 - struct StatusParserTests { 6 + final class StatusParserTests: XCTestCase { 7 7 let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 8 9 9 // MARK: - parseCommitCount 10 10 11 - @Test func parseCommitCountEmpty() { 12 - #expect(StatusParser.parseCommitCount(from: "") == 0) 11 + func testParseCommitCountEmpty() { 12 + XCTAssertEqual(StatusParser.parseCommitCount(from: ""), 0) 13 13 } 14 14 15 - @Test func parseCommitCountWhitespaceOnly() { 16 - #expect(StatusParser.parseCommitCount(from: " \n \n ") == 0) 15 + func testParseCommitCountWhitespaceOnly() { 16 + XCTAssertEqual(StatusParser.parseCommitCount(from: " \n \n "), 0) 17 17 } 18 18 19 - @Test func parseCommitCountSingleCommit() { 20 - #expect(StatusParser.parseCommitCount(from: "abc123\n") == 1) 19 + func testParseCommitCountSingleCommit() { 20 + XCTAssertEqual(StatusParser.parseCommitCount(from: "abc123\n"), 1) 21 21 } 22 22 23 - @Test func parseCommitCountSingleCommitNoNewline() { 24 - #expect(StatusParser.parseCommitCount(from: "abc123") == 1) 23 + func testParseCommitCountSingleCommitNoNewline() { 24 + XCTAssertEqual(StatusParser.parseCommitCount(from: "abc123"), 1) 25 25 } 26 26 27 - @Test func parseCommitCountMultipleCommits() { 27 + func testParseCommitCountMultipleCommits() { 28 28 let output = "abc123\ndef456\nghi789\n" 29 - #expect(StatusParser.parseCommitCount(from: output) == 3) 29 + XCTAssertEqual(StatusParser.parseCommitCount(from: output), 3) 30 30 } 31 31 32 - @Test func parseCommitCountMultipleCommitsNoTrailingNewline() { 32 + func testParseCommitCountMultipleCommitsNoTrailingNewline() { 33 33 let output = "abc123\ndef456\nghi789" 34 - #expect(StatusParser.parseCommitCount(from: output) == 3) 34 + XCTAssertEqual(StatusParser.parseCommitCount(from: output), 3) 35 35 } 36 36 37 - @Test func parseCommitCountWithLeadingTrailingWhitespace() { 37 + func testParseCommitCountWithLeadingTrailingWhitespace() { 38 38 let output = "\n abc123\ndef456 \n" 39 39 // After trimming: "abc123\ndef456" => 2 lines 40 - #expect(StatusParser.parseCommitCount(from: output) == 2) 40 + XCTAssertEqual(StatusParser.parseCommitCount(from: output), 2) 41 41 } 42 42 43 43 // MARK: - parseDirtyStatus 44 44 45 - @Test func parseDirtyStatusClean() throws { 45 + func testParseDirtyStatusClean() throws { 46 46 let result = try StatusParser.parseDirtyStatus(from: "clean") 47 - #expect(result == false) 47 + XCTAssertFalse(result) 48 48 } 49 49 50 - @Test func parseDirtyStatusDirty() throws { 50 + func testParseDirtyStatusDirty() throws { 51 51 let result = try StatusParser.parseDirtyStatus(from: "dirty") 52 - #expect(result == true) 52 + XCTAssertTrue(result) 53 53 } 54 54 55 - @Test func parseDirtyStatusCleanWithWhitespace() throws { 55 + func testParseDirtyStatusCleanWithWhitespace() throws { 56 56 let result = try StatusParser.parseDirtyStatus(from: " clean \n") 57 - #expect(result == false) 57 + XCTAssertFalse(result) 58 58 } 59 59 60 - @Test func parseDirtyStatusDirtyWithWhitespace() throws { 60 + func testParseDirtyStatusDirtyWithWhitespace() throws { 61 61 let result = try StatusParser.parseDirtyStatus(from: "\ndirty\n") 62 - #expect(result == true) 62 + XCTAssertTrue(result) 63 63 } 64 64 65 - @Test func parseDirtyStatusUnexpectedOutput() { 66 - #expect(throws: StatusParser.ParserError.self) { 67 - try StatusParser.parseDirtyStatus(from: "unknown") 68 - } 65 + func testParseDirtyStatusUnexpectedOutput() { 66 + XCTAssertThrowsError(try StatusParser.parseDirtyStatus(from: "unknown")) 69 67 } 70 68 71 - @Test func parseDirtyStatusEmptyOutput() { 72 - #expect(throws: StatusParser.ParserError.self) { 73 - try StatusParser.parseDirtyStatus(from: "") 74 - } 69 + func testParseDirtyStatusEmptyOutput() { 70 + XCTAssertThrowsError(try StatusParser.parseDirtyStatus(from: "")) 75 71 } 76 72 77 73 // MARK: - parse (combined) 78 74 79 - @Test func parseInSync() { 75 + func testParseInSync() { 80 76 let status = StatusParser.parse( 81 77 aheadOutput: "", 82 78 behindOutput: "", 83 79 dirtyOutput: "clean", 84 80 at: fixedDate 85 81 ) 86 - #expect(status.ahead == 0) 87 - #expect(status.behind == 0) 88 - #expect(!status.dirty) 89 - #expect(status.error == nil) 90 - #expect(status.isSynced) 82 + XCTAssertEqual(status.ahead, 0) 83 + XCTAssertEqual(status.behind, 0) 84 + XCTAssertFalse(status.dirty) 85 + XCTAssertNil(status.error) 86 + XCTAssertTrue(status.isSynced) 91 87 } 92 88 93 - @Test func parseAheadOnly() { 89 + func testParseAheadOnly() { 94 90 let status = StatusParser.parse( 95 91 aheadOutput: "abc123\ndef456\n", 96 92 behindOutput: "", 97 93 dirtyOutput: "clean", 98 94 at: fixedDate 99 95 ) 100 - #expect(status.ahead == 2) 101 - #expect(status.behind == 0) 102 - #expect(!status.dirty) 103 - #expect(status.isAhead) 96 + XCTAssertEqual(status.ahead, 2) 97 + XCTAssertEqual(status.behind, 0) 98 + XCTAssertFalse(status.dirty) 99 + XCTAssertTrue(status.isAhead) 104 100 } 105 101 106 - @Test func parseBehindOnly() { 102 + func testParseBehindOnly() { 107 103 let status = StatusParser.parse( 108 104 aheadOutput: "", 109 105 behindOutput: "abc123\ndef456\nghi789\n", 110 106 dirtyOutput: "clean", 111 107 at: fixedDate 112 108 ) 113 - #expect(status.ahead == 0) 114 - #expect(status.behind == 3) 115 - #expect(status.isBehind) 109 + XCTAssertEqual(status.ahead, 0) 110 + XCTAssertEqual(status.behind, 3) 111 + XCTAssertTrue(status.isBehind) 116 112 } 117 113 118 - @Test func parseDiverged() { 114 + func testParseDiverged() { 119 115 let status = StatusParser.parse( 120 116 aheadOutput: "abc123\n", 121 117 behindOutput: "def456\nghi789\n", 122 118 dirtyOutput: "dirty", 123 119 at: fixedDate 124 120 ) 125 - #expect(status.ahead == 1) 126 - #expect(status.behind == 2) 127 - #expect(status.dirty) 128 - #expect(status.isDiverged) 121 + XCTAssertEqual(status.ahead, 1) 122 + XCTAssertEqual(status.behind, 2) 123 + XCTAssertTrue(status.dirty) 124 + XCTAssertTrue(status.isDiverged) 129 125 } 130 126 131 - @Test func parseDirtyWorkingCopy() { 127 + func testParseDirtyWorkingCopy() { 132 128 let status = StatusParser.parse( 133 129 aheadOutput: "", 134 130 behindOutput: "", 135 131 dirtyOutput: "dirty", 136 132 at: fixedDate 137 133 ) 138 - #expect(status.isSynced) 139 - #expect(status.dirty) 134 + XCTAssertTrue(status.isSynced) 135 + XCTAssertTrue(status.dirty) 140 136 } 141 137 142 - @Test func parseInvalidDirtyOutputReturnsError() { 138 + func testParseInvalidDirtyOutputReturnsError() { 143 139 let status = StatusParser.parse( 144 140 aheadOutput: "abc123\n", 145 141 behindOutput: "", 146 142 dirtyOutput: "garbage", 147 143 at: fixedDate 148 144 ) 149 - #expect(status.hasError) 150 - #expect(status.error?.contains("Unexpected dirty status output") == true) 145 + XCTAssertTrue(status.hasError) 146 + XCTAssertTrue(status.error?.contains("Unexpected dirty status output") == true) 151 147 } 152 148 153 - @Test func parsePreservesDate() { 149 + func testParsePreservesDate() { 154 150 let status = StatusParser.parse( 155 151 aheadOutput: "", 156 152 behindOutput: "", 157 153 dirtyOutput: "clean", 158 154 at: fixedDate 159 155 ) 160 - #expect(status.checkedAt == fixedDate) 156 + XCTAssertEqual(status.checkedAt, fixedDate) 161 157 } 162 158 }
+2
flake.nix
··· 25 25 perSystem = 26 26 { pkgs, ... }: 27 27 { 28 + packages.default = pkgs.swiftPackages.callPackage ./package.nix { }; 29 + 28 30 treefmt = { 29 31 projectRootFile = "flake.nix"; 30 32 programs = {
+51
package.nix
··· 1 + { 2 + lib, 3 + swift, 4 + swiftpm, 5 + apple-sdk_14, 6 + }: 7 + 8 + swift.stdenv.mkDerivation { 9 + pname = "home-manager-status"; 10 + version = "0.1.0"; 11 + 12 + src = lib.fileset.toSource { 13 + root = ./.; 14 + fileset = lib.fileset.unions [ 15 + ./Package.swift 16 + ./Sources 17 + ./Tests 18 + ./Info.plist 19 + ]; 20 + }; 21 + 22 + nativeBuildInputs = [ 23 + swift 24 + swiftpm 25 + ]; 26 + 27 + buildInputs = [ 28 + apple-sdk_14 29 + ]; 30 + 31 + # buildPhase is provided automatically by swiftpm's setup hook: 32 + # swift-build -c release 33 + 34 + installPhase = '' 35 + runHook preInstall 36 + 37 + binPath="$(swiftpmBinPath)" 38 + APP="$out/Applications/HomeManagerStatus.app" 39 + mkdir -p "$APP/Contents/MacOS" 40 + 41 + cp "$binPath/HomeManagerStatus" "$APP/Contents/MacOS/HomeManagerStatus" 42 + cp ${./Info.plist} "$APP/Contents/Info.plist" 43 + 44 + runHook postInstall 45 + ''; 46 + 47 + meta = { 48 + description = "macOS menu bar app showing jj repo status for home-manager"; 49 + platforms = lib.platforms.darwin; 50 + }; 51 + }
+2 -27
scripts/bundle.sh
··· 25 25 # Copy the binary 26 26 cp "${BUILD_DIR}/${APP_NAME}" "${MACOS_DIR}/${APP_NAME}" 27 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 28 + # Copy Info.plist 29 + cp "${PROJECT_DIR}/Info.plist" "${CONTENTS_DIR}/Info.plist" 55 30 56 31 echo "Done! App bundle created at: ${APP_BUNDLE}" 57 32 echo ""