Commit 695194b61b
Verified · cmc
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr05-compare-refs.md added +300
| @@ -0,0 +1,300 @@ | ||
| 1 | # MR 5: Compare two refs Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Close the `compare two refs` parity row — pick two refs, see the diff between them. | |
| 6 | ||
| 7 | **Architecture:** Purely additive. A view model reads `repo diff <owner/name> <base> <head>`, parses its `patch` with the existing `UnifiedDiff.parse`, and renders it with the existing `DiffFileSection`. No refactor. | |
| 8 | ||
| 9 | **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` | |
| 10 | ||
| 11 | ## Correction to the spec | |
| 12 | ||
| 13 | The spec says this merge request must split `DiffView`, calls it "the only existing code the plan modifies rather than extends", and flags it as the one to expect a review round on. **That is wrong, and this plan supersedes it.** | |
| 14 | ||
| 15 | The seam already exists and already has a second caller: | |
| 16 | ||
| 17 | - `gitbay/Views/MRs/DiffView.swift` — `struct DiffFileSection` takes `var model: MRDetailViewModel? = nil`, commented "Review threads belong to merge requests; a commit diff has none." | |
| 18 | - `gitbay/Views/Repos/CommitView.swift:71` — already calls `DiffFileSection(file: file)` with no model. | |
| 19 | ||
| 20 | So compare reuses `DiffFileSection(file:)` exactly as the commit screen does. **Do not refactor `DiffView`, `DiffFileSection`, `HunkView` or `LineView`.** Touching them is out of scope and risks the merge request review screen, which is the app's most-used surface. | |
| 21 | ||
| 22 | ## Global Constraints | |
| 23 | ||
| 24 | - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. | |
| 25 | - Swift Testing only — never XCTest. | |
| 26 | - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. | |
| 27 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. | |
| 28 | - The label model is `IssueLabel`, never `Label`. | |
| 29 | - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. | |
| 30 | - Never commit to `main`. | |
| 31 | ||
| 32 | **The command, verbatim from the registry:** | |
| 33 | ||
| 34 | ``` | |
| 35 | repo diff <owner/name> <base> <head> | |
| 36 | ``` | |
| 37 | ||
| 38 | Both refs are **positional**, base first. It returns: | |
| 39 | ||
| 40 | ```json | |
| 41 | {"base":"f1cc68c…","head":"0f4f9b4…","merge_base":"0f4f9b4…","patch":"","truncated":false} | |
| 42 | ``` | |
| 43 | ||
| 44 | `patch` is unified-diff text — the same shape `UnifiedDiff.parse` already handles for `mr diff` and `repo show`'s commit patch. `truncated` says the server cut the patch short. | |
| 45 | ||
| 46 | **Counting tests reliably** — the naive grep undercounts because `xcodebuild` interleaves output: | |
| 47 | ||
| 48 | ```bash | |
| 49 | grep -cE "^Test case '[^']*' passed" /tmp/out.txt | |
| 50 | grep -cE "^Test case '[^']*' failed" /tmp/out.txt | |
| 51 | grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l # declared | |
| 52 | ``` | |
| 53 | ||
| 54 | Declared minus skipped should equal passed. One test is always skipped (`LiveInstanceTests`, conditionally enabled on a token). | |
| 55 | ||
| 56 | --- | |
| 57 | ||
| 58 | ## File Structure | |
| 59 | ||
| 60 | | File | Responsibility | | |
| 61 | |------|----------------| | |
| 62 | | `gitbay/Repos/CompareViewModel.swift` (create) | `RepoDiff` wire model + the view model over `repo diff` | | |
| 63 | | `gitbayTests/CompareTests.swift` (create) | Every test in this plan | | |
| 64 | | `gitbay/Views/Repos/CompareView.swift` (create) | Two ref pickers and the rendered patch | | |
| 65 | | `gitbay/Views/Repos/RepoRoute.swift` (modify) | `compare(repo:)` case | | |
| 66 | | `gitbay/ContentView.swift` (modify) | Route to `CompareView` | | |
| 67 | | `gitbay/Views/Repos/RefsView.swift` (modify) | Entry point | | |
| 68 | ||
| 69 | --- | |
| 70 | ||
| 71 | ### Task 1: The compare view model | |
| 72 | ||
| 73 | **Files:** Create `gitbay/Repos/CompareViewModel.swift`; test in `gitbayTests/CompareTests.swift` | |
| 74 | ||
| 75 | **Interfaces produced:** | |
| 76 | - `nonisolated struct RepoDiff: Decodable, Sendable, Hashable` — `base: String`, `head: String`, `mergeBase: String`, `patch: String`, `truncated: Bool` | |
| 77 | - `@Observable @MainActor final class CompareViewModel` — `base: String`, `head: String`, `state: LoadState<UnifiedDiff>`, `truncated: Bool`, `mergeBase: String?`, `refs: RepoRefs?`, `func loadRefs() async`, `func compare() async` | |
| 78 | ||
| 79 | Behaviour, each pinned by a test: | |
| 80 | 1. `compare()` sends exactly `["repo", "diff", repoPath, base, head]` — both positional, **base first**. Swapping them silently returns the opposite diff, which is why the test asserts the full array. | |
| 81 | 2. Comparing a ref with itself yields an empty patch, which is an `.empty` state, not `.failed`. | |
| 82 | 3. `truncated` is decoded and exposed. A partial patch rendered as though it were whole is a bug the user cannot see. | |
| 83 | 4. `mergeBase` is decoded and exposed, so the screen can say what the comparison is against. | |
| 84 | 5. Either ref being blank issues **no request** — there is nothing to compare yet. | |
| 85 | 6. `refs` comes from `repo refs`, reusing the existing `RepoRefs`/`RepoRef` models; a failure there is not fatal to the screen but must be distinguishable from "this repository has no branches" (the same three-state lesson MR 4 learned on its retarget picker: expose the refs as an optional plus an error string, not an empty array). | |
| 86 | ||
| 87 | - [ ] **Step 1: Write the failing tests** | |
| 88 | ||
| 89 | Create `gitbayTests/CompareTests.swift`: | |
| 90 | ||
| 91 | ```swift | |
| 92 | import Foundation | |
| 93 | import Testing | |
| 94 | @testable import gitbay | |
| 95 | ||
| 96 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 97 | let box = StubProtocol.box() | |
| 98 | let client = GitbayClient( | |
| 99 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 100 | token: "test-token", | |
| 101 | session: box.session() | |
| 102 | ) | |
| 103 | return (client, box) | |
| 104 | } | |
| 105 | ||
| 106 | private func argvFrom(_ url: URL) -> [String] { | |
| 107 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | |
| 108 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | |
| 109 | } | |
| 110 | ||
| 111 | private let patchJSON = """ | |
| 112 | {"protocol_version":1,"data":{"base":"aaa111","head":"bbb222","merge_base":"ccc333",\ | |
| 113 | "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",\ | |
| 114 | "truncated":false},"exit_code":0} | |
| 115 | """ | |
| 116 | ||
| 117 | struct RepoDiffDecodingTests { | |
| 118 | ||
| 119 | @Test func decodesEveryField() throws { | |
| 120 | let decoder = JSONDecoder() | |
| 121 | let json = """ | |
| 122 | {"base":"aaa111","head":"bbb222","merge_base":"ccc333","patch":"x","truncated":true} | |
| 123 | """ | |
| 124 | let diff = try decoder.decode(RepoDiff.self, from: Data(json.utf8)) | |
| 125 | #expect(diff.base == "aaa111") | |
| 126 | #expect(diff.head == "bbb222") | |
| 127 | #expect(diff.mergeBase == "ccc333") | |
| 128 | #expect(diff.truncated) | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | @MainActor | |
| 133 | struct CompareViewModelTests { | |
| 134 | ||
| 135 | @Test func bothRefsArePositionalAndBaseComesFirst() async throws { | |
| 136 | let (client, stub) = try makeClient() | |
| 137 | stub.enqueue(.init(status: 200, json: patchJSON)) | |
| 138 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 139 | model.base = "main" | |
| 140 | model.head = "feature" | |
| 141 | await model.compare() | |
| 142 | ||
| 143 | #expect(argvFrom(try #require(stub.seen.last).url) | |
| 144 | == ["repo", "diff", "krz/gitbay", "main", "feature"]) | |
| 145 | } | |
| 146 | ||
| 147 | @Test func theParsedPatchReachesTheScreen() async throws { | |
| 148 | let (client, stub) = try makeClient() | |
| 149 | stub.enqueue(.init(status: 200, json: patchJSON)) | |
| 150 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 151 | model.base = "main" | |
| 152 | model.head = "feature" | |
| 153 | await model.compare() | |
| 154 | ||
| 155 | #expect(model.state.value?.files.count == 1) | |
| 156 | #expect(model.mergeBase == "ccc333") | |
| 157 | #expect(model.truncated == false) | |
| 158 | } | |
| 159 | ||
| 160 | /// A partial patch rendered as though whole is a bug the user cannot | |
| 161 | /// see, so the flag must survive to the screen. | |
| 162 | @Test func truncationIsCarriedThrough() async throws { | |
| 163 | let (client, stub) = try makeClient() | |
| 164 | stub.enqueue(.init(status: 200, json: """ | |
| 165 | {"protocol_version":1,"data":{"base":"a","head":"b","merge_base":"c",\ | |
| 166 | "patch":"diff --git a/x b/x\\n--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-a\\n+b\\n",\ | |
| 167 | "truncated":true},"exit_code":0} | |
| 168 | """)) | |
| 169 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 170 | model.base = "main" | |
| 171 | model.head = "feature" | |
| 172 | await model.compare() | |
| 173 | ||
| 174 | #expect(model.truncated) | |
| 175 | } | |
| 176 | ||
| 177 | @Test func comparingARefWithItselfIsEmptyNotFailed() async throws { | |
| 178 | let (client, stub) = try makeClient() | |
| 179 | stub.enqueue(.init(status: 200, json: """ | |
| 180 | {"protocol_version":1,"data":{"base":"a","head":"a","merge_base":"a",\ | |
| 181 | "patch":"","truncated":false},"exit_code":0} | |
| 182 | """)) | |
| 183 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 184 | model.base = "main" | |
| 185 | model.head = "main" | |
| 186 | await model.compare() | |
| 187 | ||
| 188 | guard case .empty = model.state else { | |
| 189 | Testing.Issue.record("expected empty, got \(model.state)") | |
| 190 | return | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | @Test func aBlankRefComparesNothing() async throws { | |
| 195 | let (client, stub) = try makeClient() | |
| 196 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 197 | model.base = "main" | |
| 198 | model.head = " " | |
| 199 | await model.compare() | |
| 200 | ||
| 201 | #expect(stub.seen.isEmpty) | |
| 202 | } | |
| 203 | ||
| 204 | @Test func refsComeFromRepoRefs() async throws { | |
| 205 | let (client, stub) = try makeClient() | |
| 206 | stub.enqueue(.init(status: 200, json: """ | |
| 207 | {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\ | |
| 208 | {"name":"feature","sha":"b"}],"tags":[{"name":"v1","sha":"c"}]},"exit_code":0} | |
| 209 | """)) | |
| 210 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 211 | await model.loadRefs() | |
| 212 | ||
| 213 | #expect(model.refs?.branches.map(\.name) == ["main", "feature"]) | |
| 214 | #expect(model.refs?.tags.map(\.name) == ["v1"]) | |
| 215 | #expect(argvFrom(try #require(stub.seen.last).url) | |
| 216 | == ["repo", "refs", "krz/gitbay"]) | |
| 217 | } | |
| 218 | ||
| 219 | /// The lesson from the retarget picker: a failed ref load must not | |
| 220 | /// look like a repository with no branches. | |
| 221 | @Test func aFailedRefLoadIsDistinguishableFromNoBranches() async throws { | |
| 222 | let (client, stub) = try makeClient() | |
| 223 | stub.enqueue(.init(status: 200, json: """ | |
| 224 | {"protocol_version":1,"error":"denied","exit_code":4} | |
| 225 | """)) | |
| 226 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 227 | await model.loadRefs() | |
| 228 | ||
| 229 | #expect(model.refs == nil) | |
| 230 | #expect(model.refsError?.isEmpty == false) | |
| 231 | } | |
| 232 | ||
| 233 | @Test func aFailedCompareIsTheScreensState() async throws { | |
| 234 | let (client, stub) = try makeClient() | |
| 235 | stub.enqueue(.init(status: 200, json: """ | |
| 236 | {"protocol_version":1,"error":"unknown revision","exit_code":1} | |
| 237 | """)) | |
| 238 | let model = CompareViewModel(client: client, repoPath: "krz/gitbay") | |
| 239 | model.base = "main" | |
| 240 | model.head = "nope" | |
| 241 | await model.compare() | |
| 242 | ||
| 243 | guard case .failed = model.state else { | |
| 244 | Testing.Issue.record("expected failed, got \(model.state)") | |
| 245 | return | |
| 246 | } | |
| 247 | } | |
| 248 | } | |
| 249 | ``` | |
| 250 | ||
| 251 | - [ ] **Step 2: Run to verify failure.** | |
| 252 | - [ ] **Step 3: Implement.** `RepoDiff` with `CodingKeys` mapping `merge_base`. `CompareViewModel` holding `client`, `repoPath`, the two ref strings, `state`, `truncated`, `mergeBase`, `refs: RepoRefs?`, `refsError: String?`. `compare()` guards on both refs being non-blank, reads, parses with `UnifiedDiff.parse(diff.patch)`, and sets `.empty` when the parsed diff has no files. | |
| 253 | - [ ] **Step 4: Run the tests.** | |
| 254 | - [ ] **Step 5: Commit** — `git commit -m "Compare two refs through repo diff"` | |
| 255 | ||
| 256 | --- | |
| 257 | ||
| 258 | ### Task 2: The compare screen | |
| 259 | ||
| 260 | **Files:** Create `gitbay/Views/Repos/CompareView.swift`; modify `RepoRoute.swift`, `ContentView.swift`, `RefsView.swift` | |
| 261 | ||
| 262 | No unit tests — UI. | |
| 263 | ||
| 264 | **Reuse `DiffFileSection(file:)` with no model**, exactly as `CommitView.swift:71` does. Do not modify it. | |
| 265 | ||
| 266 | The screen: two ref pickers (base and head) fed by `model.refs`, a Compare action, then the rendered patch. Follow `RefsView`'s idiom for presenting branches and tags together. | |
| 267 | ||
| 268 | **Three things the screen must say:** | |
| 269 | 1. **When `truncated`** — a visible notice that the patch is incomplete. `GBNotice` exists for this. Rendering a cut-short diff silently is the one way this screen misleads. | |
| 270 | 2. **The merge base**, so the user knows what the comparison is against — `repo diff` reports it and a three-dot comparison against an unstated base is ambiguous. | |
| 271 | 3. **A failed ref load is not "no branches"** — render `refsError` distinctly from an empty ref list, the same way MR 4's retarget picker does. | |
| 272 | ||
| 273 | Add `RepoRoute.compare(repo:)`, wire it in `ContentView`'s `RepoRoute` switch, and add the entry point to `RefsView` (which is the branches-and-tags screen and the natural home). | |
| 274 | ||
| 275 | - [ ] **Step 1: Route case + ContentView destination** | |
| 276 | - [ ] **Step 2: `CompareView` with the two pickers and the patch** | |
| 277 | - [ ] **Step 3: Entry point in `RefsView`** | |
| 278 | - [ ] **Step 4: Build and run the full suite.** No drop. | |
| 279 | - [ ] **Step 5: Commit** — `git commit -m "Compare screen with two ref pickers"` | |
| 280 | ||
| 281 | --- | |
| 282 | ||
| 283 | ### Task 3: Flip the parity row and open the merge request | |
| 284 | ||
| 285 | - [ ] **Step 1:** In the Repositories table, `compare two refs` goes to `yes` for iOS. Touch no other row. | |
| 286 | ||
| 287 | Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. | |
| 288 | ||
| 289 | - [ ] **Step 2:** Run the full suite; record the real number. | |
| 290 | - [ ] **Step 3:** Open the merge request. | |
| 291 | ||
| 292 | --- | |
| 293 | ||
| 294 | ## Notes for whoever executes this | |
| 295 | ||
| 296 | **Do not refactor the diff views.** `DiffFileSection` is already reusable and already reused. The spec's claim that this merge request must split `DiffView` is wrong and has been superseded by this plan. | |
| 297 | ||
| 298 | **Base comes first.** `repo diff <owner/name> <base> <head>` — swapping them returns the opposite diff with no error at all. | |
| 299 | ||
| 300 | **`truncated` must reach the screen.** A partial patch that looks whole is worse than an error. | |