A macOS utility to track home-manager JJ repo status

Show individual commit descriptions in dropdown for ahead/behind status

When the repo is ahead of or behind trunk, the dropdown menu now lists
the first line of each commit description (up to 5) with · bullet
prefixes. Descriptions longer than 72 characters are truncated with ….
When there are more than 5 commits, a "... and N more" line is shown.

Also switches both ahead/behind revsets from set difference
(::@- ~ ::trunk()) to fork_point(@|trunk()) range notation, which is
more semantically correct when there is divergence between @ and trunk.
The jj template now emits description.first_line() instead of
commit_id.short(), and the parser returns both the count and the
description strings.

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

+110 -23
+50 -1
Sources/HMStatus/RepoStatus.swift
··· 10 10 public let dirty: Bool 11 11 /// If non-nil, an error occurred while checking status. 12 12 public let error: String? 13 + /// First line of each commit description for commits ahead of trunk (newest first). 14 + public let aheadCommits: [String] 15 + /// First line of each commit description for commits behind trunk (newest first). 16 + public let behindCommits: [String] 13 17 14 18 /// The last time this status was successfully checked. 15 19 public let checkedAt: Date 16 20 17 - public init(ahead: Int, behind: Int, dirty: Bool, error: String?, checkedAt: Date) { 21 + /// Maximum number of commit descriptions to display in the dropdown. 22 + public static let maxDisplayedCommits = 5 23 + /// Maximum character width for a single commit description line. 24 + public static let maxDescriptionLength = 72 25 + 26 + public init( 27 + ahead: Int, behind: Int, dirty: Bool, error: String?, 28 + aheadCommits: [String] = [], behindCommits: [String] = [], 29 + checkedAt: Date 30 + ) { 18 31 self.ahead = ahead 19 32 self.behind = behind 20 33 self.dirty = dirty 21 34 self.error = error 35 + self.aheadCommits = aheadCommits 36 + self.behindCommits = behindCommits 22 37 self.checkedAt = checkedAt 23 38 } 24 39 ··· 74 89 } else { 75 90 if ahead > 0 { 76 91 parts.append("\(ahead) commit\(ahead == 1 ? "" : "s") ahead of trunk") 92 + parts.append( 93 + contentsOf: Self.formatCommitList(aheadCommits, total: ahead)) 77 94 } 78 95 if behind > 0 { 79 96 parts.append("\(behind) commit\(behind == 1 ? "" : "s") behind trunk") 97 + parts.append( 98 + contentsOf: Self.formatCommitList(behindCommits, total: behind)) 80 99 } 81 100 } 82 101 ··· 87 106 } 88 107 89 108 return parts.joined(separator: "\n") 109 + } 110 + 111 + // MARK: - Commit list formatting 112 + 113 + /// Format a list of commit descriptions for display, with truncation. 114 + static func formatCommitList(_ commits: [String], total: Int) -> [String] { 115 + let displayCount = min(commits.count, maxDisplayedCommits) 116 + var lines: [String] = [] 117 + 118 + for i in 0..<displayCount { 119 + let desc = commits[i] 120 + let label = desc.isEmpty ? "(no description)" : truncate(desc, to: maxDescriptionLength) 121 + lines.append(" \u{00B7} \(label)") 122 + } 123 + 124 + let remaining = total - displayCount 125 + if remaining > 0 { 126 + lines.append(" \u{00B7} ... and \(remaining) more") 127 + } 128 + 129 + return lines 130 + } 131 + 132 + /// Truncate a string to a maximum length, appending "…" if needed. 133 + static func truncate(_ string: String, to maxLength: Int) -> String { 134 + if string.count <= maxLength { 135 + return string 136 + } 137 + let endIndex = string.index(string.startIndex, offsetBy: maxLength - 1) 138 + return string[string.startIndex..<endIndex] + "\u{2026}" 90 139 } 91 140 }
+8 -5
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 18 + // Use fork_point(@|trunk()) as the base for both ahead/behind checks. 19 + // This finds the best common ancestor of @ and trunk(), which is more 20 + // correct than set difference when there's divergence. 21 + // We use @- (parent of working copy) to exclude the jj working copy 19 22 // change itself — it's always present and its emptiness/dirtiness 20 23 // is tracked separately via the dirty check below. 21 24 async let aheadOutput = runner.run( 22 25 args: [ 23 26 "log", 24 - "-r", "::@- ~ ::trunk()", 27 + "-r", "fork_point(@|trunk())..@-", 25 28 "--no-graph", 26 - "-T", "commit_id.short() ++ \"\\n\"", 29 + "-T", "description.first_line() ++ \"\\n\"", 27 30 ], 28 31 repoPath: repoPath 29 32 ) ··· 31 34 async let behindOutput = runner.run( 32 35 args: [ 33 36 "log", 34 - "-r", "::trunk() ~ ::@-", 37 + "-r", "fork_point(@|trunk())..trunk()", 35 38 "--no-graph", 36 - "-T", "commit_id.short() ++ \"\\n\"", 39 + "-T", "description.first_line() ++ \"\\n\"", 37 40 ], 38 41 repoPath: repoPath 39 42 )
+10 -8
Sources/HMStatus/StatusParser.swift
··· 2 2 3 3 /// Pure functions for parsing jj command output into RepoStatus. 4 4 public enum StatusParser { 5 - /// Parse the output of `jj log` that lists commit short IDs (one per line) 6 - /// and return the count of commits. 5 + /// Parse the output of `jj log` that lists one commit description per line. 7 6 /// 8 7 /// - Parameter output: Raw stdout from jj log command 9 - /// - Returns: Number of commits listed 10 - public static func parseCommitCount(from output: String) -> Int { 8 + /// - Returns: Number of commits and their first-line descriptions 9 + public static func parseCommitLines(from output: String) -> (count: Int, descriptions: [String]) { 11 10 let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) 12 11 if trimmed.isEmpty { 13 - return 0 12 + return (0, []) 14 13 } 15 - return trimmed.components(separatedBy: "\n").count 14 + let lines = trimmed.components(separatedBy: "\n") 15 + return (lines.count, lines) 16 16 } 17 17 18 18 /// Parse the output of the dirty-check command. ··· 46 46 dirtyOutput: String, 47 47 at date: Date = Date() 48 48 ) -> RepoStatus { 49 - let ahead = parseCommitCount(from: aheadOutput) 50 - let behind = parseCommitCount(from: behindOutput) 49 + let (ahead, aheadCommits) = parseCommitLines(from: aheadOutput) 50 + let (behind, behindCommits) = parseCommitLines(from: behindOutput) 51 51 52 52 let dirty: Bool 53 53 do { ··· 64 64 behind: behind, 65 65 dirty: dirty, 66 66 error: nil, 67 + aheadCommits: aheadCommits, 68 + behindCommits: behindCommits, 67 69 checkedAt: date 68 70 ) 69 71 }
+42 -9
nix/pkgs/hm-status/integration-test.sh
··· 128 128 129 129 out=$("$HM_STATUS" "$WORK/repo3") 130 130 assert_contains "long: ahead" "2 commits ahead of trunk" "$out" 131 + assert_contains "long: ahead commit 2" "· local commit 2" "$out" 132 + assert_contains "long: ahead commit 1" "· local commit 1" "$out" 131 133 132 134 assert_exit "exit 1 with --fail (ahead)" 1 "$HM_STATUS" --fail "$WORK/repo3" 133 135 ··· 157 159 158 160 out=$("$HM_STATUS" "$WORK/repo4") 159 161 assert_contains "long: behind" "2 commits behind trunk" "$out" 162 + assert_contains "long: behind commit 2" "· remote commit 2" "$out" 163 + assert_contains "long: behind commit 1" "· remote commit 1" "$out" 160 164 161 165 assert_exit "exit 1 with --fail (behind)" 1 "$HM_STATUS" --fail "$WORK/repo4" 162 166 163 - # ── Test 5: ahead + dirty ────────────────────────────────────────── 167 + # ── Test 5: many commits behind (truncation) ────────────────────── 164 168 165 169 echo "" 166 - echo "-- ahead + dirty --" 170 + echo "-- many commits behind (truncation) --" 167 171 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 172 + 173 + initial=$(jj log -R "$WORK/repo5" -r 'main@origin' --no-graph -T 'commit_id.short()') 174 + 175 + # Create 7 commits and push them to advance main@origin. 176 + for i in $(seq 1 7); do 177 + echo "remote$i" >"${WORK}/repo5/remote${i}.txt" 178 + jj describe -m "remote change $i" -R "$WORK/repo5" >/dev/null 2>&1 179 + jj new -R "$WORK/repo5" >/dev/null 2>&1 180 + done 181 + jj bookmark set main -r @- -R "$WORK/repo5" --allow-backwards >/dev/null 2>&1 182 + jj git push --bookmark main -R "$WORK/repo5" >/dev/null 2>&1 183 + 184 + # Move back to the initial commit. 185 + jj new "$initial" -R "$WORK/repo5" >/dev/null 2>&1 173 186 174 187 out=$("$HM_STATUS" --short "$WORK/repo5") 188 + assert_eq "short output" "↓7" "$out" 189 + 190 + out=$("$HM_STATUS" "$WORK/repo5") 191 + assert_contains "long: behind" "7 commits behind trunk" "$out" 192 + assert_contains "long: shows commit 7" "· remote change 7" "$out" 193 + assert_contains "long: shows commit 3" "· remote change 3" "$out" 194 + assert_contains "long: truncation" "· ... and 2 more" "$out" 195 + 196 + # ── Test 6: ahead + dirty ────────────────────────────────────────── 197 + 198 + echo "" 199 + echo "-- ahead + dirty --" 200 + make_repo "$WORK/repo6" 201 + echo "change" >"${WORK}/repo6/change.txt" 202 + jj describe -m "local commit" -R "$WORK/repo6" >/dev/null 2>&1 203 + jj new -R "$WORK/repo6" >/dev/null 2>&1 204 + echo "uncommitted" >"${WORK}/repo6/uncommitted.txt" 205 + jj log -R "$WORK/repo6" -r @ --no-graph -T 'empty' >/dev/null 2>&1 206 + 207 + out=$("$HM_STATUS" --short "$WORK/repo6") 175 208 assert_eq "short output" "↑1●" "$out" 176 209 177 - assert_exit "exit 1 with --fail (ahead+dirty)" 1 "$HM_STATUS" --fail "$WORK/repo5" 210 + assert_exit "exit 1 with --fail (ahead+dirty)" 1 "$HM_STATUS" --fail "$WORK/repo6" 178 211 179 - # ── Test 6: error case (not a jj repo) ───────────────────────────── 212 + # ── Test 7: error case (not a jj repo) ───────────────────────────── 180 213 181 214 echo "" 182 215 echo "-- error case --"