A macOS utility to track home-manager JJ repo status

Add hm-status cross-platform CLI and integration tests

- Add Sources/hm-status/HMStatusCLI.swift: cross-platform CLI with --short, --fail, --help
- Add nix/pkgs/hm-status/integration-test.sh: comprehensive test suite (6 scenarios)
- Synced + clean, dirty, ahead, behind, ahead+dirty, error cases
- Add passthru.tests.integration to hm-status derivation; wire into flake checks
- Update JujutsuCommandRunner: find jj on PATH directly (fixes Linux nix sandbox)
- Restructure nix/pkgs/: move derivations into subdirectories for auto-discovery
- nix/pkgs/hm-status/package.nix: CLI derivation (macOS + Linux)
- nix/pkgs/app/package.nix: macOS app derivation (swiftPackages pattern)
- Update Package.swift: conditionally include HomeManagerStatus target on macOS only
- Update flake.nix: use packagesFromDirectoryRecursive; add hm-status checks
- Add .tangled/workflows/check.yml: CI for build + integration tests

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

+513 -541
+21
.tangled/workflows/check.yml
··· 1 + # Build and integration tests using nix. 2 + # Builds hm-status CLI, then exercises it against scripted jj repositories. 3 + 4 + when: 5 + - event: ["push"] 6 + branch: ["main"] 7 + - event: ["pull_request"] 8 + branch: ["main"] 9 + 10 + engine: "nixery" 11 + 12 + dependencies: 13 + nixpkgs: 14 + - nix 15 + - git 16 + 17 + steps: 18 + - name: "Build hm-status" 19 + command: "nix build .#hm-status" 20 + - name: "Integration tests" 21 + command: "nix build .#hm-status.tests.integration"
+32 -22
Package.swift
··· 2 2 3 3 import PackageDescription 4 4 5 + var products: [Product] = [ 6 + .library(name: "HMStatus", targets: ["HMStatus"]), 7 + .executable(name: "hm-status", targets: ["hm-status"]), 8 + ] 9 + 10 + var targets: [Target] = [ 11 + // Cross-platform library — pure Foundation, no Apple frameworks 12 + .target( 13 + name: "HMStatus", 14 + path: "Sources/HMStatus" 15 + ), 16 + // Cross-platform CLI tool 17 + .executableTarget( 18 + name: "hm-status", 19 + dependencies: ["HMStatus"], 20 + path: "Sources/hm-status" 21 + ), 22 + ] 23 + 24 + #if os(macOS) 25 + targets.append( 26 + // macOS menu bar app — requires SwiftUI 27 + .executableTarget( 28 + name: "HomeManagerStatus", 29 + dependencies: ["HMStatus"], 30 + path: "Sources/HomeManagerStatus" 31 + ) 32 + ) 33 + #endif 34 + 5 35 let package = Package( 6 36 name: "HomeManagerStatus", 7 37 platforms: [ 8 38 .macOS(.v13) 9 39 ], 10 - products: [ 11 - .library(name: "HMStatus", targets: ["HMStatus"]) 12 - ], 13 - targets: [ 14 - // Cross-platform library — pure Foundation, no Apple frameworks 15 - .target( 16 - name: "HMStatus", 17 - path: "Sources/HMStatus" 18 - ), 19 - // macOS menu bar app 20 - .executableTarget( 21 - name: "HomeManagerStatus", 22 - dependencies: ["HMStatus"], 23 - path: "Sources/HomeManagerStatus" 24 - ), 25 - // Cross-platform tests for the library 26 - .testTarget( 27 - name: "HMStatusTests", 28 - dependencies: ["HMStatus"], 29 - path: "Tests/HMStatusTests" 30 - ), 31 - ] 40 + products: products, 41 + targets: targets 32 42 )
+28 -4
Sources/HMStatus/JujutsuCommandRunner.swift
··· 35 35 ] 36 36 } 37 37 38 + /// Search PATH directories for an executable named `name`. 39 + static func findExecutable(named name: String, in paths: [String]) -> String? { 40 + let fm = FileManager.default 41 + for dir in paths { 42 + let candidate = "\(dir)/\(name)" 43 + if fm.isExecutableFile(atPath: candidate) { 44 + return candidate 45 + } 46 + } 47 + return nil 48 + } 49 + 38 50 public func run(args: [String], repoPath: String) async throws -> String { 39 51 try await withCheckedThrowingContinuation { continuation in 40 52 let process = Process() 41 - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") 42 - process.arguments = ["jj"] + args 43 53 44 - // Build environment with Nix-aware PATH 54 + // Build the full PATH (extra paths + existing PATH) 45 55 var env = ProcessInfo.processInfo.environment 46 56 let existingPath = env["PATH"] ?? "/usr/bin:/bin" 47 57 let extraPaths = 48 58 additionalPaths.isEmpty 49 59 ? Self.nixAwarePaths() 50 60 : additionalPaths 51 - env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") 61 + let fullPath = (extraPaths + [existingPath]).joined(separator: ":") 62 + let searchDirs = fullPath.components(separatedBy: ":") 63 + 64 + // Find jj on PATH directly instead of relying on /usr/bin/env 65 + // (which doesn't exist in nix sandboxes on Linux). 66 + guard let jjPath = Self.findExecutable(named: "jj", in: searchDirs) else { 67 + continuation.resume( 68 + throwing: CommandError.launchFailed("jj not found in PATH: \(fullPath)")) 69 + return 70 + } 71 + 72 + process.executableURL = URL(fileURLWithPath: jjPath) 73 + process.arguments = args 74 + 75 + env["PATH"] = fullPath 52 76 process.environment = env 53 77 54 78 process.currentDirectoryURL = URL(fileURLWithPath: repoPath)
+5 -2
Sources/HMStatus/StatusChecker.swift
··· 15 15 at date: Date = Date() 16 16 ) async -> RepoStatus { 17 17 do { 18 + // Use @- (parent of working copy) to exclude the jj working copy 19 + // change itself — it's always present and its emptiness/dirtiness 20 + // is tracked separately via the dirty check below. 18 21 async let aheadOutput = runner.run( 19 22 args: [ 20 23 "log", 21 - "-r", "::@ ~ ::trunk()", 24 + "-r", "::@- ~ ::trunk()", 22 25 "--no-graph", 23 26 "-T", "commit_id.short() ++ \"\\n\"", 24 27 ], ··· 28 31 async let behindOutput = runner.run( 29 32 args: [ 30 33 "log", 31 - "-r", "::trunk() ~ ::@", 34 + "-r", "::trunk() ~ ::@-", 32 35 "--no-graph", 33 36 "-T", "commit_id.short() ++ \"\\n\"", 34 37 ],
+78
Sources/hm-status/HMStatusCLI.swift
··· 1 + import Foundation 2 + import HMStatus 3 + 4 + @main 5 + struct HMStatusCLI { 6 + static func main() async { 7 + let args = CommandLine.arguments.dropFirst() 8 + 9 + var shortOutput = false 10 + var failOnOutOfSync = false 11 + var repoPath: String? 12 + 13 + for arg in args { 14 + switch arg { 15 + case "--short": 16 + shortOutput = true 17 + case "--fail": 18 + failOnOutOfSync = true 19 + case "--help", "-h": 20 + printUsage() 21 + exit(0) 22 + default: 23 + if arg.hasPrefix("-") { 24 + fputs("Unknown option: \(arg)\n", stderr) 25 + printUsage() 26 + exit(2) 27 + } 28 + repoPath = arg 29 + } 30 + } 31 + 32 + let path = repoPath ?? defaultRepoPath() 33 + 34 + let status = await StatusChecker.check(repoPath: path) 35 + 36 + if status.hasError { 37 + fputs("\(status.statusDescription)\n", stderr) 38 + exit(2) 39 + } 40 + 41 + if shortOutput { 42 + print(status.menuBarLabel) 43 + } else { 44 + print(status.statusDescription) 45 + } 46 + 47 + if failOnOutOfSync && !status.isSynced { 48 + exit(1) 49 + } 50 + } 51 + 52 + static func defaultRepoPath() -> String { 53 + let home = FileManager.default.homeDirectoryForCurrentUser.path 54 + return "\(home)/.config/home-manager" 55 + } 56 + 57 + static func printUsage() { 58 + let usage = """ 59 + Usage: hm-status [OPTIONS] [PATH] 60 + 61 + Check jj repository status relative to trunk. 62 + 63 + Arguments: 64 + PATH Path to jj repository (default: ~/.config/home-manager) 65 + 66 + Options: 67 + --short Print compact status (e.g. ✓, ↑2●, ↓3) 68 + --fail Exit with code 1 when out of sync 69 + --help Show this help message 70 + 71 + Exit codes: 72 + 0 Success (or out of sync without --fail) 73 + 1 Out of sync (only with --fail) 74 + 2 Error (jj not found, invalid repo, etc.) 75 + """ 76 + print(usage) 77 + } 78 + }
-169
Tests/HMStatusTests/RepoStatusTests.swift
··· 1 - import XCTest 2 - 3 - @testable import HMStatus 4 - 5 - final class RepoStatusTests: XCTestCase { 6 - let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 7 - 8 - // MARK: - Boolean state properties 9 - 10 - func testSyncedStatus() { 11 - let status = RepoStatus(ahead: 0, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 12 - XCTAssertTrue(status.isSynced) 13 - XCTAssertFalse(status.isAhead) 14 - XCTAssertFalse(status.isBehind) 15 - XCTAssertFalse(status.isDiverged) 16 - XCTAssertFalse(status.hasError) 17 - } 18 - 19 - func testAheadOnly() { 20 - let status = RepoStatus(ahead: 3, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 21 - XCTAssertFalse(status.isSynced) 22 - XCTAssertTrue(status.isAhead) 23 - XCTAssertFalse(status.isBehind) 24 - XCTAssertFalse(status.isDiverged) 25 - } 26 - 27 - func testBehindOnly() { 28 - let status = RepoStatus(ahead: 0, behind: 5, dirty: false, error: nil, checkedAt: fixedDate) 29 - XCTAssertFalse(status.isSynced) 30 - XCTAssertFalse(status.isAhead) 31 - XCTAssertTrue(status.isBehind) 32 - XCTAssertFalse(status.isDiverged) 33 - } 34 - 35 - func testDiverged() { 36 - let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 37 - XCTAssertFalse(status.isSynced) 38 - XCTAssertTrue(status.isAhead) 39 - XCTAssertTrue(status.isBehind) 40 - XCTAssertTrue(status.isDiverged) 41 - } 42 - 43 - func testErrorState() { 44 - let status = RepoStatus.error("jj not found", at: fixedDate) 45 - XCTAssertTrue(status.hasError) 46 - XCTAssertFalse(status.isSynced) 47 - XCTAssertFalse(status.isAhead) 48 - XCTAssertFalse(status.isBehind) 49 - XCTAssertFalse(status.isDiverged) 50 - } 51 - 52 - // MARK: - Menu bar text 53 - 54 - func testMenuBarTextSynced() { 55 - let status = RepoStatus.synced(at: fixedDate) 56 - XCTAssertEqual(status.menuBarText, "") 57 - } 58 - 59 - func testMenuBarTextAhead() { 60 - let status = RepoStatus(ahead: 2, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 61 - XCTAssertEqual(status.menuBarText, "\u{2191}2") 62 - } 63 - 64 - func testMenuBarTextBehind() { 65 - let status = RepoStatus(ahead: 0, behind: 7, dirty: false, error: nil, checkedAt: fixedDate) 66 - XCTAssertEqual(status.menuBarText, "\u{2193}7") 67 - } 68 - 69 - func testMenuBarTextDiverged() { 70 - let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 71 - XCTAssertEqual(status.menuBarText, "\u{2191}2\u{2193}3") 72 - } 73 - 74 - func testMenuBarTextError() { 75 - let status = RepoStatus.error("oops", at: fixedDate) 76 - XCTAssertEqual(status.menuBarText, "!") 77 - } 78 - 79 - func testMenuBarTextLargeNumbers() { 80 - let status = RepoStatus( 81 - ahead: 42, behind: 100, dirty: false, error: nil, checkedAt: fixedDate) 82 - XCTAssertEqual(status.menuBarText, "\u{2191}42\u{2193}100") 83 - } 84 - 85 - // MARK: - Menu bar label (text + dirty dot) 86 - 87 - func testMenuBarLabelSyncedClean() { 88 - let status = RepoStatus.synced(dirty: false, at: fixedDate) 89 - XCTAssertEqual(status.menuBarLabel, "\u{2713}") 90 - } 91 - 92 - func testMenuBarLabelSyncedDirty() { 93 - let status = RepoStatus.synced(dirty: true, at: fixedDate) 94 - XCTAssertEqual(status.menuBarLabel, "\u{25CF}") 95 - } 96 - 97 - func testMenuBarLabelAheadDirty() { 98 - let status = RepoStatus(ahead: 2, behind: 0, dirty: true, error: nil, checkedAt: fixedDate) 99 - XCTAssertEqual(status.menuBarLabel, "\u{2191}2\u{25CF}") 100 - } 101 - 102 - func testMenuBarLabelErrorNoDot() { 103 - let status = RepoStatus( 104 - ahead: 0, behind: 0, dirty: true, error: "err", checkedAt: fixedDate) 105 - XCTAssertEqual(status.menuBarLabel, "!") 106 - } 107 - 108 - // MARK: - Status description 109 - 110 - func testStatusDescriptionSynced() { 111 - let status = RepoStatus.synced(at: fixedDate) 112 - XCTAssertTrue(status.statusDescription.contains("In sync with trunk")) 113 - XCTAssertTrue(status.statusDescription.contains("Working copy is clean")) 114 - } 115 - 116 - func testStatusDescriptionAhead() { 117 - let status = RepoStatus(ahead: 1, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 118 - XCTAssertTrue(status.statusDescription.contains("1 commit ahead of trunk")) 119 - } 120 - 121 - func testStatusDescriptionAheadPlural() { 122 - let status = RepoStatus(ahead: 5, behind: 0, dirty: false, error: nil, checkedAt: fixedDate) 123 - XCTAssertTrue(status.statusDescription.contains("5 commits ahead of trunk")) 124 - } 125 - 126 - func testStatusDescriptionBehind() { 127 - let status = RepoStatus(ahead: 0, behind: 1, dirty: false, error: nil, checkedAt: fixedDate) 128 - XCTAssertTrue(status.statusDescription.contains("1 commit behind trunk")) 129 - } 130 - 131 - func testStatusDescriptionBehindPlural() { 132 - let status = RepoStatus(ahead: 0, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 133 - XCTAssertTrue(status.statusDescription.contains("3 commits behind trunk")) 134 - } 135 - 136 - func testStatusDescriptionDiverged() { 137 - let status = RepoStatus(ahead: 2, behind: 3, dirty: false, error: nil, checkedAt: fixedDate) 138 - XCTAssertTrue(status.statusDescription.contains("2 commits ahead of trunk")) 139 - XCTAssertTrue(status.statusDescription.contains("3 commits behind trunk")) 140 - } 141 - 142 - func testStatusDescriptionDirty() { 143 - let status = RepoStatus(ahead: 0, behind: 0, dirty: true, error: nil, checkedAt: fixedDate) 144 - XCTAssertTrue(status.statusDescription.contains("Working copy has changes")) 145 - } 146 - 147 - func testStatusDescriptionError() { 148 - let status = RepoStatus.error("jj not found", at: fixedDate) 149 - XCTAssertTrue(status.statusDescription.contains("Error: jj not found")) 150 - } 151 - 152 - // MARK: - Convenience initializers 153 - 154 - func testSyncedConvenience() { 155 - let status = RepoStatus.synced(dirty: true, at: fixedDate) 156 - XCTAssertEqual(status.ahead, 0) 157 - XCTAssertEqual(status.behind, 0) 158 - XCTAssertTrue(status.dirty) 159 - XCTAssertNil(status.error) 160 - } 161 - 162 - func testErrorConvenience() { 163 - let status = RepoStatus.error("boom", at: fixedDate) 164 - XCTAssertEqual(status.ahead, 0) 165 - XCTAssertEqual(status.behind, 0) 166 - XCTAssertFalse(status.dirty) 167 - XCTAssertEqual(status.error, "boom") 168 - } 169 - }
-126
Tests/HMStatusTests/StatusCheckerTests.swift
··· 1 - import Foundation 2 - import XCTest 3 - 4 - @testable import HMStatus 5 - 6 - // MARK: - Mock Command Runner 7 - 8 - /// A mock JujutsuCommandRunner that returns predefined responses based on arguments. 9 - struct MockJujutsuCommandRunner: JujutsuCommandRunner { 10 - let responses: ([String]) -> Result<String, Error> 11 - 12 - init(responses: @escaping ([String]) -> Result<String, Error>) { 13 - self.responses = responses 14 - } 15 - 16 - /// Create a mock that returns fixed outputs for ahead, behind, and dirty queries. 17 - static func fixed(ahead: String, behind: String, dirty: String) -> MockJujutsuCommandRunner { 18 - MockJujutsuCommandRunner { args in 19 - // Identify which command is being run by inspecting the revset argument 20 - if let revsetIndex = args.firstIndex(of: "-r"), 21 - revsetIndex + 1 < args.count 22 - { 23 - let revset = args[revsetIndex + 1] 24 - if revset.contains("::@ ~ ::trunk()") { 25 - return .success(ahead) 26 - } else if revset.contains("::trunk() ~ ::@") { 27 - return .success(behind) 28 - } else if revset == "@" { 29 - return .success(dirty) 30 - } 31 - } 32 - return .failure(MockError.unexpectedArgs(args)) 33 - } 34 - } 35 - 36 - /// Create a mock that always fails. 37 - static func failing(error: String) -> MockJujutsuCommandRunner { 38 - MockJujutsuCommandRunner { _ in 39 - .failure(MockError.simulated(error)) 40 - } 41 - } 42 - 43 - func run(args: [String], repoPath: String) async throws -> String { 44 - switch responses(args) { 45 - case .success(let output): 46 - return output 47 - case .failure(let error): 48 - throw error 49 - } 50 - } 51 - 52 - enum MockError: LocalizedError { 53 - case unexpectedArgs([String]) 54 - case simulated(String) 55 - 56 - var errorDescription: String? { 57 - switch self { 58 - case .unexpectedArgs(let args): 59 - return "Unexpected args: \(args)" 60 - case .simulated(let msg): 61 - return msg 62 - } 63 - } 64 - } 65 - } 66 - 67 - // MARK: - StatusChecker Tests 68 - 69 - final class StatusCheckerTests: XCTestCase { 70 - 71 - func testCheckSynced() async { 72 - let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "clean") 73 - let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 74 - 75 - XCTAssertTrue(status.isSynced) 76 - XCTAssertFalse(status.dirty) 77 - XCTAssertNil(status.error) 78 - } 79 - 80 - func testCheckAhead() async { 81 - let runner = MockJujutsuCommandRunner.fixed( 82 - ahead: "abc123\ndef456\n", behind: "", dirty: "clean") 83 - let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 84 - 85 - XCTAssertEqual(status.ahead, 2) 86 - XCTAssertEqual(status.behind, 0) 87 - XCTAssertTrue(status.isAhead) 88 - } 89 - 90 - func testCheckBehind() async { 91 - let runner = MockJujutsuCommandRunner.fixed( 92 - ahead: "", behind: "abc123\ndef456\nghi789\n", dirty: "clean") 93 - let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 94 - 95 - XCTAssertEqual(status.ahead, 0) 96 - XCTAssertEqual(status.behind, 3) 97 - XCTAssertTrue(status.isBehind) 98 - } 99 - 100 - func testCheckDiverged() async { 101 - let runner = MockJujutsuCommandRunner.fixed( 102 - ahead: "abc123\n", behind: "def456\nghi789\n", dirty: "dirty") 103 - let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 104 - 105 - XCTAssertEqual(status.ahead, 1) 106 - XCTAssertEqual(status.behind, 2) 107 - XCTAssertTrue(status.dirty) 108 - XCTAssertTrue(status.isDiverged) 109 - } 110 - 111 - func testCheckDirtyOnly() async { 112 - let runner = MockJujutsuCommandRunner.fixed(ahead: "", behind: "", dirty: "dirty") 113 - let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 114 - 115 - XCTAssertTrue(status.isSynced) 116 - XCTAssertTrue(status.dirty) 117 - } 118 - 119 - func testCheckWithError() async { 120 - let runner = MockJujutsuCommandRunner.failing(error: "jj: command not found") 121 - let status = await StatusChecker.check(repoPath: "/tmp/test", runner: runner) 122 - 123 - XCTAssertTrue(status.hasError) 124 - XCTAssertTrue(status.error?.contains("jj: command not found") == true) 125 - } 126 - }
-158
Tests/HMStatusTests/StatusParserTests.swift
··· 1 - import Foundation 2 - import XCTest 3 - 4 - @testable import HMStatus 5 - 6 - final class StatusParserTests: XCTestCase { 7 - let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 8 - 9 - // MARK: - parseCommitCount 10 - 11 - func testParseCommitCountEmpty() { 12 - XCTAssertEqual(StatusParser.parseCommitCount(from: ""), 0) 13 - } 14 - 15 - func testParseCommitCountWhitespaceOnly() { 16 - XCTAssertEqual(StatusParser.parseCommitCount(from: " \n \n "), 0) 17 - } 18 - 19 - func testParseCommitCountSingleCommit() { 20 - XCTAssertEqual(StatusParser.parseCommitCount(from: "abc123\n"), 1) 21 - } 22 - 23 - func testParseCommitCountSingleCommitNoNewline() { 24 - XCTAssertEqual(StatusParser.parseCommitCount(from: "abc123"), 1) 25 - } 26 - 27 - func testParseCommitCountMultipleCommits() { 28 - let output = "abc123\ndef456\nghi789\n" 29 - XCTAssertEqual(StatusParser.parseCommitCount(from: output), 3) 30 - } 31 - 32 - func testParseCommitCountMultipleCommitsNoTrailingNewline() { 33 - let output = "abc123\ndef456\nghi789" 34 - XCTAssertEqual(StatusParser.parseCommitCount(from: output), 3) 35 - } 36 - 37 - func testParseCommitCountWithLeadingTrailingWhitespace() { 38 - let output = "\n abc123\ndef456 \n" 39 - // After trimming: "abc123\ndef456" => 2 lines 40 - XCTAssertEqual(StatusParser.parseCommitCount(from: output), 2) 41 - } 42 - 43 - // MARK: - parseDirtyStatus 44 - 45 - func testParseDirtyStatusClean() throws { 46 - let result = try StatusParser.parseDirtyStatus(from: "clean") 47 - XCTAssertFalse(result) 48 - } 49 - 50 - func testParseDirtyStatusDirty() throws { 51 - let result = try StatusParser.parseDirtyStatus(from: "dirty") 52 - XCTAssertTrue(result) 53 - } 54 - 55 - func testParseDirtyStatusCleanWithWhitespace() throws { 56 - let result = try StatusParser.parseDirtyStatus(from: " clean \n") 57 - XCTAssertFalse(result) 58 - } 59 - 60 - func testParseDirtyStatusDirtyWithWhitespace() throws { 61 - let result = try StatusParser.parseDirtyStatus(from: "\ndirty\n") 62 - XCTAssertTrue(result) 63 - } 64 - 65 - func testParseDirtyStatusUnexpectedOutput() { 66 - XCTAssertThrowsError(try StatusParser.parseDirtyStatus(from: "unknown")) 67 - } 68 - 69 - func testParseDirtyStatusEmptyOutput() { 70 - XCTAssertThrowsError(try StatusParser.parseDirtyStatus(from: "")) 71 - } 72 - 73 - // MARK: - parse (combined) 74 - 75 - func testParseInSync() { 76 - let status = StatusParser.parse( 77 - aheadOutput: "", 78 - behindOutput: "", 79 - dirtyOutput: "clean", 80 - at: fixedDate 81 - ) 82 - XCTAssertEqual(status.ahead, 0) 83 - XCTAssertEqual(status.behind, 0) 84 - XCTAssertFalse(status.dirty) 85 - XCTAssertNil(status.error) 86 - XCTAssertTrue(status.isSynced) 87 - } 88 - 89 - func testParseAheadOnly() { 90 - let status = StatusParser.parse( 91 - aheadOutput: "abc123\ndef456\n", 92 - behindOutput: "", 93 - dirtyOutput: "clean", 94 - at: fixedDate 95 - ) 96 - XCTAssertEqual(status.ahead, 2) 97 - XCTAssertEqual(status.behind, 0) 98 - XCTAssertFalse(status.dirty) 99 - XCTAssertTrue(status.isAhead) 100 - } 101 - 102 - func testParseBehindOnly() { 103 - let status = StatusParser.parse( 104 - aheadOutput: "", 105 - behindOutput: "abc123\ndef456\nghi789\n", 106 - dirtyOutput: "clean", 107 - at: fixedDate 108 - ) 109 - XCTAssertEqual(status.ahead, 0) 110 - XCTAssertEqual(status.behind, 3) 111 - XCTAssertTrue(status.isBehind) 112 - } 113 - 114 - func testParseDiverged() { 115 - let status = StatusParser.parse( 116 - aheadOutput: "abc123\n", 117 - behindOutput: "def456\nghi789\n", 118 - dirtyOutput: "dirty", 119 - at: fixedDate 120 - ) 121 - XCTAssertEqual(status.ahead, 1) 122 - XCTAssertEqual(status.behind, 2) 123 - XCTAssertTrue(status.dirty) 124 - XCTAssertTrue(status.isDiverged) 125 - } 126 - 127 - func testParseDirtyWorkingCopy() { 128 - let status = StatusParser.parse( 129 - aheadOutput: "", 130 - behindOutput: "", 131 - dirtyOutput: "dirty", 132 - at: fixedDate 133 - ) 134 - XCTAssertTrue(status.isSynced) 135 - XCTAssertTrue(status.dirty) 136 - } 137 - 138 - func testParseInvalidDirtyOutputReturnsError() { 139 - let status = StatusParser.parse( 140 - aheadOutput: "abc123\n", 141 - behindOutput: "", 142 - dirtyOutput: "garbage", 143 - at: fixedDate 144 - ) 145 - XCTAssertTrue(status.hasError) 146 - XCTAssertTrue(status.error?.contains("Unexpected dirty status output") == true) 147 - } 148 - 149 - func testParsePreservesDate() { 150 - let status = StatusParser.parse( 151 - aheadOutput: "", 152 - behindOutput: "", 153 - dirtyOutput: "clean", 154 - at: fixedDate 155 - ) 156 - XCTAssertEqual(status.checkedAt, fixedDate) 157 - } 158 - }
+13 -3
flake.nix
··· 1 1 { 2 - description = "HomeManagerStatus — macOS menu bar app for jj repo status"; 2 + description = "HomeManagerStatus — macOS menu bar app and CLI for jj repo status"; 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; ··· 32 32 { 33 33 pkgs, 34 34 lib, 35 + self', 35 36 ... 36 37 }: 37 38 { 38 - packages = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin { 39 - default = pkgs.swiftPackages.callPackage ./nix/package.nix { }; 39 + packages = 40 + lib.packagesFromDirectoryRecursive { 41 + callPackage = pkgs.callPackage; 42 + directory = ./nix/pkgs; 43 + } 44 + // lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin { 45 + default = self'.packages.app; 46 + }; 47 + 48 + checks = { 49 + hm-status-integration = self'.packages.hm-status.tests.integration; 40 50 }; 41 51 42 52 treefmt = {
-57
nix/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 - # Tests cannot run during nix build: SwiftPM's `swift-test` requires 32 - # `xcrun --find xctest` which needs Xcode, and corelibs-xctest in nix 33 - # only provides the library, not the runner. No Swift package in nixpkgs 34 - # has solved this — all have doCheck disabled or commented out. 35 - doCheck = false; 36 - 37 - # buildPhase is provided automatically by swiftpm's setup hook: 38 - # swift-build -c release 39 - 40 - installPhase = '' 41 - runHook preInstall 42 - 43 - binPath="$(swiftpmBinPath)" 44 - APP="$out/Applications/HomeManagerStatus.app" 45 - mkdir -p "$APP/Contents/MacOS" 46 - 47 - cp "$binPath/HomeManagerStatus" "$APP/Contents/MacOS/HomeManagerStatus" 48 - cp ${./../Info.plist} "$APP/Contents/Info.plist" 49 - 50 - runHook postInstall 51 - ''; 52 - 53 - meta = { 54 - description = "macOS menu bar app showing jj repo status for home-manager"; 55 - platforms = lib.platforms.darwin; 56 - }; 57 - }
+56
nix/pkgs/app/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + swiftPackages, 5 + swift ? swiftPackages.swift, 6 + swiftpm ? swiftPackages.swiftpm, 7 + apple-sdk_14, 8 + }: 9 + 10 + swift.stdenv.mkDerivation { 11 + pname = "jj-home-manager-status"; 12 + version = "0.1.0"; 13 + 14 + src = lib.fileset.toSource { 15 + root = ../../..; 16 + fileset = lib.fileset.unions [ 17 + ../../../Package.swift 18 + ../../../Sources 19 + ../../../Info.plist 20 + ]; 21 + }; 22 + 23 + nativeBuildInputs = [ 24 + swift 25 + swiftpm 26 + ]; 27 + 28 + buildInputs = [ 29 + apple-sdk_14 30 + ]; 31 + 32 + swiftpmFlags = [ "--product HomeManagerStatus" ]; 33 + 34 + # swift-test cannot run in the nix sandbox (needs Xcode). 35 + # Integration tests run via the hm-status CLI package (passthru.tests). 36 + doCheck = false; 37 + 38 + installPhase = '' 39 + runHook preInstall 40 + 41 + binPath="$(swiftpmBinPath)" 42 + APP="$out/Applications/HomeManagerStatus.app" 43 + mkdir -p "$APP/Contents/MacOS" 44 + 45 + install -Dm755 "$binPath/HomeManagerStatus" "$APP/Contents/MacOS/HomeManagerStatus" 46 + install -Dm644 ${../../../Info.plist} "$APP/Contents/Info.plist" 47 + 48 + runHook postInstall 49 + ''; 50 + 51 + meta = { 52 + description = "macOS menu bar app showing jj repo status for home-manager"; 53 + mainProgram = "HomeManagerStatus"; 54 + platforms = lib.platforms.darwin; 55 + }; 56 + }
+197
nix/pkgs/hm-status/integration-test.sh
··· 1 + #!/usr/bin/env bash 2 + # Integration test for hm-status CLI. 3 + # Exercises the real binary against scripted jj repositories. 4 + set -euo pipefail 5 + 6 + HM_STATUS="${1:?Usage: $0 <path-to-hm-status>}" 7 + failures=0 8 + 9 + # ── helpers ──────────────────────────────────────────────────────── 10 + 11 + pass() { echo " ✓ $1"; } 12 + fail() { 13 + echo " ✗ $1" 14 + echo " expected: $2" 15 + echo " got: $3" 16 + failures=$((failures + 1)) 17 + } 18 + 19 + assert_eq() { 20 + local label="$1" expected="$2" actual="$3" 21 + if [ "$expected" = "$actual" ]; then 22 + pass "$label" 23 + else 24 + fail "$label" "$expected" "$actual" 25 + fi 26 + } 27 + 28 + assert_contains() { 29 + local label="$1" needle="$2" haystack="$3" 30 + if echo "$haystack" | grep -qF "$needle"; then 31 + pass "$label" 32 + else 33 + fail "$label" "contains '$needle'" "$haystack" 34 + fi 35 + } 36 + 37 + assert_exit() { 38 + local label="$1" expected="$2" 39 + shift 2 40 + local actual=0 41 + "$@" >/dev/null 2>&1 || actual=$? 42 + assert_eq "$label" "$expected" "$actual" 43 + } 44 + 45 + # Create a fresh jj repo backed by a bare git "origin" remote. 46 + # This gives us trunk() = main@origin. 47 + make_repo() { 48 + local repo="$1" 49 + local origin="${repo}.origin" 50 + 51 + git init --bare "$origin" >/dev/null 2>&1 52 + 53 + jj git init "$repo" >/dev/null 2>&1 54 + jj git remote add origin "$origin" -R "$repo" 2>/dev/null 55 + 56 + # Seed with an initial commit so main exists. 57 + echo "init" >"${repo}/file.txt" 58 + jj describe -m "initial commit" -R "$repo" >/dev/null 2>&1 59 + jj new -R "$repo" >/dev/null 2>&1 60 + jj bookmark set main -r @- -R "$repo" >/dev/null 2>&1 61 + jj git push --bookmark main -R "$repo" >/dev/null 2>&1 62 + } 63 + 64 + WORK=$(mktemp -d) 65 + trap 'rm -rf "$WORK"' EXIT 66 + export HOME="$WORK/fakehome" 67 + mkdir -p "$HOME" 68 + 69 + # jj needs user config 70 + mkdir -p "$HOME/.config/jj" 71 + cat >"$HOME/.config/jj/config.toml" <<'TOML' 72 + [user] 73 + name = "Test" 74 + email = "test@test" 75 + TOML 76 + 77 + echo "=== hm-status integration tests ===" 78 + 79 + # ── Test 1: synced + clean ───────────────────────────────────────── 80 + 81 + echo "" 82 + echo "-- synced + clean --" 83 + make_repo "$WORK/repo1" 84 + 85 + out=$("$HM_STATUS" --short "$WORK/repo1") 86 + assert_eq "short output" "✓" "$out" 87 + 88 + out=$("$HM_STATUS" "$WORK/repo1") 89 + assert_contains "long: in sync" "In sync with trunk" "$out" 90 + assert_contains "long: clean" "Working copy is clean" "$out" 91 + 92 + assert_exit "exit 0 without --fail" 0 "$HM_STATUS" "$WORK/repo1" 93 + assert_exit "exit 0 with --fail (synced)" 0 "$HM_STATUS" --fail "$WORK/repo1" 94 + 95 + # ── Test 2: dirty working copy ───────────────────────────────────── 96 + 97 + echo "" 98 + echo "-- synced + dirty --" 99 + make_repo "$WORK/repo2" 100 + echo "dirty" >"${WORK}/repo2/dirty.txt" 101 + # Trigger jj snapshot so it sees the change 102 + jj log -R "$WORK/repo2" -r @ --no-graph -T 'empty' >/dev/null 2>&1 103 + 104 + out=$("$HM_STATUS" --short "$WORK/repo2") 105 + assert_eq "short output" "●" "$out" 106 + 107 + out=$("$HM_STATUS" "$WORK/repo2") 108 + assert_contains "long: in sync" "In sync with trunk" "$out" 109 + assert_contains "long: has changes" "Working copy has changes" "$out" 110 + 111 + # Dirty but synced → --fail should still exit 0 112 + assert_exit "exit 0 with --fail (dirty but synced)" 0 "$HM_STATUS" --fail "$WORK/repo2" 113 + 114 + # ── Test 3: ahead of trunk ───────────────────────────────────────── 115 + 116 + echo "" 117 + echo "-- ahead of trunk --" 118 + make_repo "$WORK/repo3" 119 + echo "change1" >"${WORK}/repo3/change1.txt" 120 + jj describe -m "local commit 1" -R "$WORK/repo3" >/dev/null 2>&1 121 + jj new -R "$WORK/repo3" >/dev/null 2>&1 122 + echo "change2" >"${WORK}/repo3/change2.txt" 123 + jj describe -m "local commit 2" -R "$WORK/repo3" >/dev/null 2>&1 124 + jj new -R "$WORK/repo3" >/dev/null 2>&1 125 + 126 + out=$("$HM_STATUS" --short "$WORK/repo3") 127 + assert_eq "short output" "↑2" "$out" 128 + 129 + out=$("$HM_STATUS" "$WORK/repo3") 130 + assert_contains "long: ahead" "2 commits ahead of trunk" "$out" 131 + 132 + assert_exit "exit 1 with --fail (ahead)" 1 "$HM_STATUS" --fail "$WORK/repo3" 133 + 134 + # ── Test 4: behind trunk ─────────────────────────────────────────── 135 + 136 + echo "" 137 + echo "-- behind trunk --" 138 + make_repo "$WORK/repo4" 139 + 140 + # Save the initial commit (what main@origin currently points to). 141 + initial=$(jj log -R "$WORK/repo4" -r 'main@origin' --no-graph -T 'commit_id.short()') 142 + 143 + # Add commits and push them to advance main@origin. 144 + echo "remote1" >"${WORK}/repo4/remote1.txt" 145 + jj describe -m "remote commit 1" -R "$WORK/repo4" >/dev/null 2>&1 146 + jj new -R "$WORK/repo4" >/dev/null 2>&1 147 + echo "remote2" >"${WORK}/repo4/remote2.txt" 148 + jj describe -m "remote commit 2" -R "$WORK/repo4" >/dev/null 2>&1 149 + jj bookmark set main -r @ -R "$WORK/repo4" --allow-backwards >/dev/null 2>&1 150 + jj git push --bookmark main -R "$WORK/repo4" >/dev/null 2>&1 151 + 152 + # Move the working copy back to the initial commit so we are behind. 153 + jj new "$initial" -R "$WORK/repo4" >/dev/null 2>&1 154 + 155 + out=$("$HM_STATUS" --short "$WORK/repo4") 156 + assert_eq "short output" "↓2" "$out" 157 + 158 + out=$("$HM_STATUS" "$WORK/repo4") 159 + assert_contains "long: behind" "2 commits behind trunk" "$out" 160 + 161 + assert_exit "exit 1 with --fail (behind)" 1 "$HM_STATUS" --fail "$WORK/repo4" 162 + 163 + # ── Test 5: ahead + dirty ────────────────────────────────────────── 164 + 165 + echo "" 166 + echo "-- ahead + dirty --" 167 + make_repo "$WORK/repo5" 168 + echo "change" >"${WORK}/repo5/change.txt" 169 + jj describe -m "local commit" -R "$WORK/repo5" >/dev/null 2>&1 170 + jj new -R "$WORK/repo5" >/dev/null 2>&1 171 + echo "uncommitted" >"${WORK}/repo5/uncommitted.txt" 172 + jj log -R "$WORK/repo5" -r @ --no-graph -T 'empty' >/dev/null 2>&1 173 + 174 + out=$("$HM_STATUS" --short "$WORK/repo5") 175 + assert_eq "short output" "↑1●" "$out" 176 + 177 + assert_exit "exit 1 with --fail (ahead+dirty)" 1 "$HM_STATUS" --fail "$WORK/repo5" 178 + 179 + # ── Test 6: error case (not a jj repo) ───────────────────────────── 180 + 181 + echo "" 182 + echo "-- error case --" 183 + mkdir -p "$WORK/not-a-repo" 184 + 185 + assert_exit "exit 2 on invalid repo" 2 "$HM_STATUS" "$WORK/not-a-repo" 186 + assert_exit "exit 2 on invalid repo (short)" 2 "$HM_STATUS" --short "$WORK/not-a-repo" 187 + 188 + # ── Summary ───────────────────────────────────────────────────────── 189 + 190 + echo "" 191 + if [ "$failures" -eq 0 ]; then 192 + echo "All tests passed!" 193 + exit 0 194 + else 195 + echo "$failures test(s) FAILED" 196 + exit 1 197 + fi
+83
nix/pkgs/hm-status/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + swiftPackages, 5 + swift ? swiftPackages.swift, 6 + swiftpm ? swiftPackages.swiftpm, 7 + Foundation ? swiftPackages.Foundation, 8 + Dispatch ? swiftPackages.Dispatch, 9 + apple-sdk_14 ? null, 10 + runCommand, 11 + jujutsu, 12 + gitMinimal, 13 + }: 14 + 15 + swift.stdenv.mkDerivation (finalAttrs: { 16 + pname = "hm-status"; 17 + version = "0.1.0"; 18 + 19 + src = lib.fileset.toSource { 20 + root = ../../..; 21 + fileset = lib.fileset.unions [ 22 + ../../../Package.swift 23 + ../../../Sources 24 + ]; 25 + }; 26 + 27 + nativeBuildInputs = [ 28 + swift 29 + swiftpm 30 + ]; 31 + 32 + buildInputs = 33 + lib.optionals stdenv.hostPlatform.isDarwin [ 34 + apple-sdk_14 35 + ] 36 + ++ lib.optionals stdenv.hostPlatform.isLinux [ 37 + Foundation 38 + ]; 39 + 40 + env = lib.optionalAttrs stdenv.hostPlatform.isLinux { 41 + LD_LIBRARY_PATH = lib.makeLibraryPath [ Dispatch ]; 42 + }; 43 + 44 + swiftpmFlags = [ "--product hm-status" ]; 45 + 46 + # swift-test cannot run in the nix sandbox (needs Xcode on macOS, 47 + # libIndexStore.so on Linux). Integration tests run via passthru.tests. 48 + doCheck = false; 49 + 50 + installPhase = '' 51 + runHook preInstall 52 + 53 + binPath="$(swiftpmBinPath)" 54 + install -Dm755 "$binPath/hm-status" "$out/bin/hm-status" 55 + 56 + runHook postInstall 57 + ''; 58 + 59 + passthru.tests = { 60 + integration = 61 + let 62 + hm-status = finalAttrs.finalPackage; 63 + in 64 + runCommand "hm-status-integration-test" 65 + { 66 + nativeBuildInputs = [ 67 + hm-status 68 + jujutsu 69 + gitMinimal 70 + ]; 71 + } 72 + '' 73 + bash ${./integration-test.sh} ${lib.getExe hm-status} 74 + touch $out 75 + ''; 76 + }; 77 + 78 + meta = { 79 + description = "CLI tool showing jj repo status relative to trunk"; 80 + mainProgram = "hm-status"; 81 + platforms = lib.platforms.darwin ++ lib.platforms.linux; 82 + }; 83 + })