Commit d80ba74bc1

d80ba74bc11dbffbb22d4e2fa8611f6d1efebeb2

parent: 695194b61b

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 21:55 UTC

Compare two refs through repo diff

Layout: unified · split

gitbay/Repos/CompareViewModel.swift added +74
@@ -0,0 +1,74 @@
1import Foundation
2import Observation
3
4/// `repo diff <owner/name> <base> <head>` — the same unified-diff shape as
5/// `mr diff` and a commit patch, parsed the same way.
6nonisolated struct RepoDiff: Decodable, Sendable, Hashable {
7 let base: String
8 let head: String
9 let mergeBase: String
10 let patch: String
11 let truncated: Bool
12
13 enum CodingKeys: String, CodingKey {
14 case base, head, patch, truncated
15 case mergeBase = "merge_base"
16 }
17}
18
19/// Compares two refs in a repo. Both refs are positional and base comes
20/// first — swapping them silently returns the opposite diff with no error.
21@Observable
22@MainActor
23final class CompareViewModel {
24
25 var base = ""
26 var head = ""
27 private(set) var state: LoadState<UnifiedDiff> = .loading
28 private(set) var truncated = false
29 private(set) var mergeBase: String?
30
31 /// `repo refs`, for pickers. Exposed as an optional plus an error
32 /// string rather than an empty array, so "failed to load" stays
33 /// distinguishable from "this repository has no branches".
34 private(set) var refs: RepoRefs?
35 private(set) var refsError: String?
36
37 private let client: GitbayClient
38 let repoPath: String
39
40 init(client: GitbayClient, repoPath: String) {
41 self.client = client
42 self.repoPath = repoPath
43 }
44
45 func loadRefs() async {
46 refsError = nil
47 do {
48 refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
49 } catch let error as GitbayError {
50 refsError = error.userFacingMessage
51 } catch {
52 refsError = GitbayError.transport(error).userFacingMessage
53 }
54 }
55
56 func compare() async {
57 let base = base.trimmingCharacters(in: .whitespaces)
58 let head = head.trimmingCharacters(in: .whitespaces)
59 guard !base.isEmpty, !head.isEmpty else { return }
60
61 state = .loading
62 do {
63 let diff = try await client.read(["repo", "diff", repoPath, base, head], as: RepoDiff.self)
64 truncated = diff.truncated
65 mergeBase = diff.mergeBase
66 let parsed = UnifiedDiff.parse(diff.patch)
67 state = parsed.files.isEmpty
68 ? .empty("No differences between \(base) and \(head).")
69 : .loaded(parsed)
70 } catch {
71 state = .from(error)
72 }
73 }
74}
gitbayTests/CompareTests.swift added +157
@@ -0,0 +1,157 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20private let patchJSON = """
21 {"protocol_version":1,"data":{"base":"aaa111","head":"bbb222","merge_base":"ccc333",\
22 "patch":"diff --git a/x.txt b/x.txt\\n--- a/x.txt\\n+++ b/x.txt\\n@@ -1 +1 @@\\n-old\\n+new\\n",\
23 "truncated":false},"exit_code":0}
24 """
25
26struct RepoDiffDecodingTests {
27
28 @Test func decodesEveryField() throws {
29 let decoder = JSONDecoder()
30 let json = """
31 {"base":"aaa111","head":"bbb222","merge_base":"ccc333","patch":"x","truncated":true}
32 """
33 let diff = try decoder.decode(RepoDiff.self, from: Data(json.utf8))
34 #expect(diff.base == "aaa111")
35 #expect(diff.head == "bbb222")
36 #expect(diff.mergeBase == "ccc333")
37 #expect(diff.truncated)
38 }
39}
40
41@MainActor
42struct CompareViewModelTests {
43
44 @Test func bothRefsArePositionalAndBaseComesFirst() async throws {
45 let (client, stub) = try makeClient()
46 stub.enqueue(.init(status: 200, json: patchJSON))
47 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
48 model.base = "main"
49 model.head = "feature"
50 await model.compare()
51
52 #expect(argvFrom(try #require(stub.seen.last).url)
53 == ["repo", "diff", "krz/gitbay", "main", "feature"])
54 }
55
56 @Test func theParsedPatchReachesTheScreen() async throws {
57 let (client, stub) = try makeClient()
58 stub.enqueue(.init(status: 200, json: patchJSON))
59 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
60 model.base = "main"
61 model.head = "feature"
62 await model.compare()
63
64 #expect(model.state.value?.files.count == 1)
65 #expect(model.mergeBase == "ccc333")
66 #expect(model.truncated == false)
67 }
68
69 /// A partial patch rendered as though whole is a bug the user cannot
70 /// see, so the flag must survive to the screen.
71 @Test func truncationIsCarriedThrough() async throws {
72 let (client, stub) = try makeClient()
73 stub.enqueue(.init(status: 200, json: """
74 {"protocol_version":1,"data":{"base":"a","head":"b","merge_base":"c",\
75 "patch":"diff --git a/x b/x\\n--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-a\\n+b\\n",\
76 "truncated":true},"exit_code":0}
77 """))
78 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
79 model.base = "main"
80 model.head = "feature"
81 await model.compare()
82
83 #expect(model.truncated)
84 }
85
86 @Test func comparingARefWithItselfIsEmptyNotFailed() async throws {
87 let (client, stub) = try makeClient()
88 stub.enqueue(.init(status: 200, json: """
89 {"protocol_version":1,"data":{"base":"a","head":"a","merge_base":"a",\
90 "patch":"","truncated":false},"exit_code":0}
91 """))
92 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
93 model.base = "main"
94 model.head = "main"
95 await model.compare()
96
97 guard case .empty = model.state else {
98 Testing.Issue.record("expected empty, got \(model.state)")
99 return
100 }
101 }
102
103 @Test func aBlankRefComparesNothing() async throws {
104 let (client, stub) = try makeClient()
105 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
106 model.base = "main"
107 model.head = " "
108 await model.compare()
109
110 #expect(stub.seen.isEmpty)
111 }
112
113 @Test func refsComeFromRepoRefs() async throws {
114 let (client, stub) = try makeClient()
115 stub.enqueue(.init(status: 200, json: """
116 {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
117 {"name":"feature","sha":"b"}],"tags":[{"name":"v1","sha":"c"}]},"exit_code":0}
118 """))
119 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
120 await model.loadRefs()
121
122 #expect(model.refs?.branches.map(\.name) == ["main", "feature"])
123 #expect(model.refs?.tags.map(\.name) == ["v1"])
124 #expect(argvFrom(try #require(stub.seen.last).url)
125 == ["repo", "refs", "krz/gitbay"])
126 }
127
128 /// The lesson from the retarget picker: a failed ref load must not
129 /// look like a repository with no branches.
130 @Test func aFailedRefLoadIsDistinguishableFromNoBranches() async throws {
131 let (client, stub) = try makeClient()
132 stub.enqueue(.init(status: 200, json: """
133 {"protocol_version":1,"error":"denied","exit_code":4}
134 """))
135 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
136 await model.loadRefs()
137
138 #expect(model.refs == nil)
139 #expect(model.refsError?.isEmpty == false)
140 }
141
142 @Test func aFailedCompareIsTheScreensState() async throws {
143 let (client, stub) = try makeClient()
144 stub.enqueue(.init(status: 200, json: """
145 {"protocol_version":1,"error":"unknown revision","exit_code":1}
146 """))
147 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
148 model.base = "main"
149 model.head = "nope"
150 await model.compare()
151
152 guard case .failed = model.state else {
153 Testing.Issue.record("expected failed, got \(model.state)")
154 return
155 }
156 }
157}