A macOS utility to track home-manager JJ repo status
1import Foundation
2
3/// Represents the current status of the jj repository relative to trunk().
4public struct RepoStatus: Equatable {
5 /// Number of commits on the current working copy ancestry that are not on trunk().
6 public let ahead: Int
7 /// Number of commits on trunk() that are not on the current working copy ancestry.
8 public let behind: Int
9 /// Whether the working copy has uncommitted changes (non-empty).
10 public let dirty: Bool
11 /// If non-nil, an error occurred while checking status.
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]
17
18 /// The last time this status was successfully checked.
19 public let checkedAt: Date
20
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 ) {
31 self.ahead = ahead
32 self.behind = behind
33 self.dirty = dirty
34 self.error = error
35 self.aheadCommits = aheadCommits
36 self.behindCommits = behindCommits
37 self.checkedAt = checkedAt
38 }
39
40 // MARK: - Convenience initializers
41
42 public static func synced(dirty: Bool = false, at date: Date = Date()) -> RepoStatus {
43 RepoStatus(ahead: 0, behind: 0, dirty: dirty, error: nil, checkedAt: date)
44 }
45
46 public static func error(_ message: String, at date: Date = Date()) -> RepoStatus {
47 RepoStatus(ahead: 0, behind: 0, dirty: false, error: message, checkedAt: date)
48 }
49
50 // MARK: - Computed properties
51
52 public var isSynced: Bool { ahead == 0 && behind == 0 && error == nil }
53 public var isAhead: Bool { ahead > 0 && error == nil }
54 public var isBehind: Bool { behind > 0 && error == nil }
55 public var isDiverged: Bool { ahead > 0 && behind > 0 && error == nil }
56 public var hasError: Bool { error != nil }
57
58 /// Compact text shown in the menu bar.
59 public var menuBarText: String {
60 if hasError { return "!" }
61 if isDiverged { return "\u{2191}\(ahead)\u{2193}\(behind)" }
62 if isAhead { return "\u{2191}\(ahead)" }
63 if isBehind { return "\u{2193}\(behind)" }
64 return ""
65 }
66
67 /// Full text for the menu bar label.
68 public var menuBarLabel: String {
69 var label = menuBarText
70 if dirty && !hasError {
71 label += "\u{25CF}" // ● dot
72 }
73 if label.isEmpty {
74 return "\u{2713}" // ✓ checkmark
75 }
76 return label
77 }
78
79 /// Human-readable status description for the dropdown.
80 public var statusDescription: String {
81 if hasError {
82 return "Error: \(error!)"
83 }
84
85 var parts: [String] = []
86
87 if isSynced {
88 parts.append("In sync with trunk")
89 } else {
90 if ahead > 0 {
91 parts.append("\(ahead) commit\(ahead == 1 ? "" : "s") ahead of trunk")
92 parts.append(
93 contentsOf: Self.formatCommitList(aheadCommits, total: ahead))
94 }
95 if behind > 0 {
96 parts.append("\(behind) commit\(behind == 1 ? "" : "s") behind trunk")
97 parts.append(
98 contentsOf: Self.formatCommitList(behindCommits, total: behind))
99 }
100 }
101
102 if dirty {
103 parts.append("Working copy has changes")
104 } else if !hasError {
105 parts.append("Working copy is clean")
106 }
107
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}"
139 }
140}