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 | } | ||