Commit d80ba74bc1
Verified · cmc
Layout: unified · split
gitbay/Repos/CompareViewModel.swift added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 6 | nonisolated 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 | |
| 23 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private func argvFrom(_ url: URL) -> [String] { | |
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | |
| 17 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | |
| 18 | } | |
| 19 | ||
| 20 | private 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 | ||
| 26 | struct 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 | |
| 42 | struct 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 | } | |