Skip to content

Commit a97630e

Browse files
authored
Add visual branch comparison workflow (#19)
1 parent b8128c9 commit a97630e

25 files changed

Lines changed: 1123 additions & 139 deletions
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
import Observation
4+
5+
@MainActor @Observable
6+
final class ReferenceComparisonController {
7+
let repositoryURL: URL
8+
let isBranchComparison: Bool
9+
let title: String
10+
private(set) var baseRef: String
11+
private(set) var targetRef: String
12+
private(set) var mode: ReferenceComparisonMode
13+
private(set) var branches: [ComparisonBranch] = []
14+
private(set) var snapshot: ReferenceComparisonSnapshot?
15+
private(set) var files: [CommitFileChange] = []
16+
private(set) var baseCommits: [Commit] = []
17+
private(set) var targetCommits: [Commit] = []
18+
private(set) var selectedFile: CommitFileChange?
19+
private(set) var patch: ReferenceComparisonPatch?
20+
private(set) var error: String?
21+
private(set) var fileError: String?
22+
private(set) var baseCommitsError: String?
23+
private(set) var targetCommitsError: String?
24+
private(set) var isLoading = false
25+
private(set) var isLoadingPatch = false
26+
private(set) var isLoadingBaseCommits = false
27+
private(set) var isLoadingTargetCommits = false
28+
private(set) var isCancelled = false
29+
@ObservationIgnored private let service: any ReferenceComparisonServing
30+
@ObservationIgnored private var requestID = UUID()
31+
@ObservationIgnored private var patchID = UUID()
32+
@ObservationIgnored private(set) var loadTask: Task<Void, Never>?
33+
@ObservationIgnored private(set) var patchTask: Task<Void, Never>?
34+
@ObservationIgnored private(set) var baseCommitsTask: Task<Void, Never>?
35+
@ObservationIgnored private(set) var targetCommitsTask: Task<Void, Never>?
36+
37+
init(repositoryURL: URL, baseRef: String, targetRef: String, isBranchComparison: Bool = true,
38+
title: String = "Compare Branches", service: any ReferenceComparisonServing = GitStatusService.shared) {
39+
self.repositoryURL = repositoryURL
40+
self.baseRef = baseRef
41+
self.targetRef = targetRef
42+
self.isBranchComparison = isBranchComparison
43+
self.title = title
44+
self.service = service
45+
self.mode = isBranchComparison ? .mergeBase : .tips
46+
}
47+
48+
func setBase(_ ref: String) {
49+
guard baseRef != ref else { return }
50+
baseRef = ref
51+
reload()
52+
}
53+
54+
func setTarget(_ ref: String) {
55+
guard targetRef != ref else { return }
56+
targetRef = ref
57+
reload()
58+
}
59+
60+
func swap() {
61+
(baseRef, targetRef) = (targetRef, baseRef)
62+
reload()
63+
}
64+
65+
func setMode(_ mode: ReferenceComparisonMode) {
66+
guard self.mode != mode else { return }
67+
self.mode = mode
68+
reload(reuseSnapshot: true)
69+
}
70+
71+
func cancel() {
72+
requestID = UUID()
73+
patchID = UUID()
74+
loadTask?.cancel()
75+
patchTask?.cancel()
76+
baseCommitsTask?.cancel()
77+
targetCommitsTask?.cancel()
78+
isLoading = false
79+
isLoadingPatch = false
80+
isLoadingBaseCommits = false
81+
isLoadingTargetCommits = false
82+
isCancelled = true
83+
}
84+
85+
func reload(reuseSnapshot: Bool = false) {
86+
let previousSnapshot = reuseSnapshot ? snapshot : nil
87+
cancel()
88+
let id = requestID
89+
let base = baseRef
90+
let target = targetRef
91+
let requestedMode = mode
92+
isCancelled = false
93+
isLoading = true
94+
error = nil
95+
fileError = nil
96+
files = []
97+
selectedFile = nil
98+
patch = nil
99+
snapshot = previousSnapshot
100+
if previousSnapshot == nil {
101+
baseCommits = []
102+
targetCommits = []
103+
}
104+
baseCommitsError = nil
105+
targetCommitsError = nil
106+
loadTask = Task { [weak self] in
107+
guard let self else { return }
108+
do {
109+
if isBranchComparison && (branches.isEmpty || !reuseSnapshot) {
110+
let available = try await service.comparisonBranches(in: repositoryURL)
111+
guard isCurrent(id) else { return }
112+
branches = available
113+
}
114+
guard !base.isEmpty, !target.isEmpty else {
115+
isLoading = false
116+
return
117+
}
118+
let resolved: ReferenceComparisonSnapshot
119+
if let previousSnapshot {
120+
resolved = previousSnapshot
121+
} else {
122+
resolved = try await service.comparisonSnapshot(base: base, target: target,
123+
branchesOnly: isBranchComparison, in: repositoryURL)
124+
}
125+
guard isCurrent(id) else { return }
126+
snapshot = resolved
127+
if isBranchComparison {
128+
if baseCommits.isEmpty { loadMoreCommits(targetSide: false) }
129+
if targetCommits.isEmpty { loadMoreCommits(targetSide: true) }
130+
}
131+
let changes = try await service.comparisonFiles(snapshot: resolved, mode: requestedMode, in: repositoryURL)
132+
guard isCurrent(id) else { return }
133+
files = changes
134+
isLoading = false
135+
} catch {
136+
guard isCurrent(id) else { return }
137+
self.error = error.localizedDescription
138+
isLoading = false
139+
}
140+
}
141+
}
142+
143+
func selectFile(_ file: CommitFileChange?) {
144+
patchTask?.cancel()
145+
patchID = UUID()
146+
selectedFile = file
147+
patch = nil
148+
fileError = nil
149+
isLoadingPatch = false
150+
guard let file, let snapshot, files.contains(file), !isCancelled else { return }
151+
let id = requestID
152+
let fileID = patchID
153+
let requestedMode = mode
154+
isLoadingPatch = true
155+
patchTask = Task { [weak self] in
156+
guard let self else { return }
157+
do {
158+
let result = try await service.comparisonPatch(file: file, snapshot: snapshot, mode: requestedMode, in: repositoryURL)
159+
guard isCurrent(id), patchID == fileID else { return }
160+
patch = result
161+
isLoadingPatch = false
162+
} catch {
163+
guard isCurrent(id), patchID == fileID else { return }
164+
fileError = error.localizedDescription
165+
isLoadingPatch = false
166+
}
167+
}
168+
}
169+
170+
func loadMoreCommits(targetSide: Bool) {
171+
guard let snapshot, !isCancelled else { return }
172+
let count = targetSide ? targetCommits.count : baseCommits.count
173+
let total = targetSide ? snapshot.targetOnlyCount : snapshot.baseOnlyCount
174+
guard count < total, !(targetSide ? isLoadingTargetCommits : isLoadingBaseCommits) else { return }
175+
let id = requestID
176+
if targetSide {
177+
isLoadingTargetCommits = true
178+
targetCommitsError = nil
179+
} else {
180+
isLoadingBaseCommits = true
181+
baseCommitsError = nil
182+
}
183+
let task = Task { [weak self] in
184+
guard let self else { return }
185+
do {
186+
let commits = try await service.comparisonCommits(snapshot: snapshot, targetSide: targetSide,
187+
skip: count, limit: 100, in: repositoryURL)
188+
guard isCurrent(id) else { return }
189+
if targetSide {
190+
targetCommits += commits
191+
isLoadingTargetCommits = false
192+
} else {
193+
baseCommits += commits
194+
isLoadingBaseCommits = false
195+
}
196+
} catch {
197+
guard isCurrent(id) else { return }
198+
if targetSide {
199+
targetCommitsError = error.localizedDescription
200+
isLoadingTargetCommits = false
201+
} else {
202+
baseCommitsError = error.localizedDescription
203+
isLoadingBaseCommits = false
204+
}
205+
}
206+
}
207+
if targetSide { targetCommitsTask = task } else { baseCommitsTask = task }
208+
}
209+
210+
private func isCurrent(_ id: UUID) -> Bool {
211+
!Task.isCancelled && requestID == id
212+
}
213+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
4+
nonisolated struct ComparisonBranch: Identifiable, Hashable, Sendable {
5+
let ref: String
6+
var id: String { ref }
7+
var isRemote: Bool { ref.hasPrefix("refs/remotes/") }
8+
var name: String {
9+
String(ref.dropFirst(isRemote ? "refs/remotes/".count : "refs/heads/".count))
10+
}
11+
var label: String { "\(name) (\(isRemote ? "remote" : "local"))" }
12+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
4+
nonisolated enum ReferenceComparisonMode: String, CaseIterable, Identifiable, Sendable {
5+
case mergeBase
6+
case tips
7+
8+
var id: Self { self }
9+
var title: String {
10+
switch self {
11+
case .mergeBase: "Changes since merge base"
12+
case .tips: "Tip-to-tip"
13+
}
14+
}
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
4+
nonisolated struct ReferenceComparisonPatch: Sendable {
5+
let hunks: [DiffHunk]
6+
let isBinary: Bool
7+
let isTruncated: Bool
8+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
4+
nonisolated struct ReferenceComparisonSnapshot: Sendable {
5+
let base: String
6+
let target: String
7+
let mergeBases: [String]
8+
let baseOnlyCount: Int
9+
let targetOnlyCount: Int
10+
11+
func diffBase(for mode: ReferenceComparisonMode) throws -> String {
12+
if mode == .tips { return base }
13+
guard mergeBases.count == 1, let mergeBase = mergeBases.first else {
14+
throw GitError.commandFailed(mergeBases.isEmpty
15+
? "These branches have no common ancestor. Select Tip-to-tip to compare their trees."
16+
: "These branches have multiple merge bases. Select Tip-to-tip to compare their trees.")
17+
}
18+
return mergeBase
19+
}
20+
}

‎macgit/Services/Commit.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@ nonisolated struct Commit: Identifiable, Equatable, Sendable {
4848

4949
// MARK: - Commit File Change
5050

51-
struct CommitFileChange: Identifiable, Hashable {
51+
nonisolated struct CommitFileChange: Identifiable, Hashable, Sendable {
5252
let id = UUID()
5353
let path: String
5454
let status: CommitFileStatus
55+
var oldPath: String? = nil
5556
}
5657

57-
enum CommitFileStatus: String {
58+
nonisolated enum CommitFileStatus: String, Sendable {
5859
case added = "A"
5960
case modified = "M"
6061
case deleted = "D"

‎macgit/Services/GitDiffModels.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,29 @@
2222
//
2323
import Foundation
2424

25-
enum DiffLineType: Sendable {
25+
nonisolated enum DiffLineType: Sendable {
2626
case context
2727
case added
2828
case removed
2929
case header
3030
case conflictMarker
3131
}
3232

33-
struct DiffLine: Identifiable, Sendable {
33+
nonisolated struct DiffLine: Identifiable, Sendable {
3434
let id = UUID()
3535
let oldLineNumber: Int?
3636
let newLineNumber: Int?
3737
let text: String
3838
let type: DiffLineType
3939
}
4040

41-
struct DiffHunk: Identifiable, Sendable {
41+
nonisolated struct DiffHunk: Identifiable, Sendable {
4242
let id = UUID()
4343
let header: String
4444
let lines: [DiffLine]
4545
}
4646

47-
enum DiffParser {
47+
nonisolated enum DiffParser {
4848
static func parse(_ raw: String) -> [DiffHunk] {
4949
var hunks: [DiffHunk] = []
5050
var currentLines: [DiffLine] = []

0 commit comments

Comments
 (0)