docs/superpowers/plans/2026-09-06-mr05-compare-refs.md
300 lines · 13882 bytes
MR 5: Compare two refs Implementation Plan
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.
Goal: Close the compare two refs parity row — pick two refs, see the diff between them.
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.
Spec: docs/superpowers/specs/2026-09-06-ios-parity-design.md
Correction to the spec
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.
The seam already exists and already has a second caller:
gitbay/Views/MRs/DiffView.swift—struct DiffFileSectiontakesvar model: MRDetailViewModel? = nil, commented "Review threads belong to merge requests; a commit diff has none."gitbay/Views/Repos/CommitView.swift:71— already callsDiffFileSection(file: file)with no model.
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.
Global Constraints
- Swift 6 language mode, default
MainActorisolation. Wire models arenonisolated structs. - Swift Testing only — never XCTest.
gitbayTestsis hermetic and offline; network goes throughStubProtocol.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit. - The label model is
IssueLabel, neverLabel. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No
Co-Authored-Bytrailer. - Never commit to
main.
The command, verbatim from the registry:
repo diff <owner/name> <base> <head>
Both refs are positional, base first. It returns:
{"base":"f1cc68c…","head":"0f4f9b4…","merge_base":"0f4f9b4…","patch":"","truncated":false}
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.
Counting tests reliably — the naive grep undercounts because xcodebuild interleaves output:
grep -cE "^Test case '[^']*' passed" /tmp/out.txt
grep -cE "^Test case '[^']*' failed" /tmp/out.txt
grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l # declared
Declared minus skipped should equal passed. One test is always skipped (LiveInstanceTests, conditionally enabled on a token).
File Structure
| File | Responsibility |
|---|---|
gitbay/Repos/CompareViewModel.swift (create) |
RepoDiff wire model + the view model over repo diff |
gitbayTests/CompareTests.swift (create) |
Every test in this plan |
gitbay/Views/Repos/CompareView.swift (create) |
Two ref pickers and the rendered patch |
gitbay/Views/Repos/RepoRoute.swift (modify) |
compare(repo:) case |
gitbay/ContentView.swift (modify) |
Route to CompareView |
gitbay/Views/Repos/RefsView.swift (modify) |
Entry point |
Task 1: The compare view model
Files: Create gitbay/Repos/CompareViewModel.swift; test in gitbayTests/CompareTests.swift
Interfaces produced:
nonisolated struct RepoDiff: Decodable, Sendable, Hashable—base: String,head: String,mergeBase: String,patch: String,truncated: Bool@Observable @MainActor final class CompareViewModel—base: String,head: String,state: LoadState<UnifiedDiff>,truncated: Bool,mergeBase: String?,refs: RepoRefs?,func loadRefs() async,func compare() async
Behaviour, each pinned by a test:
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.- Comparing a ref with itself yields an empty patch, which is an
.emptystate, not.failed. truncatedis decoded and exposed. A partial patch rendered as though it were whole is a bug the user cannot see.mergeBaseis decoded and exposed, so the screen can say what the comparison is against.- Either ref being blank issues no request — there is nothing to compare yet.
refscomes fromrepo refs, reusing the existingRepoRefs/RepoRefmodels; 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).
- Step 1: Write the failing tests
Create gitbayTests/CompareTests.swift:
import Foundation
import Testing
@testable import gitbay
private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
let box = StubProtocol.box()
let client = GitbayClient(
instance: try GitbayInstance(url: "https://gitbay.org"),
token: "test-token",
session: box.session()
)
return (client, box)
}
private func argvFrom(_ url: URL) -> [String] {
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
}
private let patchJSON = """
{"protocol_version":1,"data":{"base":"aaa111","head":"bbb222","merge_base":"ccc333",\
"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",\
"truncated":false},"exit_code":0}
"""
struct RepoDiffDecodingTests {
@Test func decodesEveryField() throws {
let decoder = JSONDecoder()
let json = """
{"base":"aaa111","head":"bbb222","merge_base":"ccc333","patch":"x","truncated":true}
"""
let diff = try decoder.decode(RepoDiff.self, from: Data(json.utf8))
#expect(diff.base == "aaa111")
#expect(diff.head == "bbb222")
#expect(diff.mergeBase == "ccc333")
#expect(diff.truncated)
}
}
@MainActor
struct CompareViewModelTests {
@Test func bothRefsArePositionalAndBaseComesFirst() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: patchJSON))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
model.base = "main"
model.head = "feature"
await model.compare()
#expect(argvFrom(try #require(stub.seen.last).url)
== ["repo", "diff", "krz/gitbay", "main", "feature"])
}
@Test func theParsedPatchReachesTheScreen() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: patchJSON))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
model.base = "main"
model.head = "feature"
await model.compare()
#expect(model.state.value?.files.count == 1)
#expect(model.mergeBase == "ccc333")
#expect(model.truncated == false)
}
/// A partial patch rendered as though whole is a bug the user cannot
/// see, so the flag must survive to the screen.
@Test func truncationIsCarriedThrough() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: """
{"protocol_version":1,"data":{"base":"a","head":"b","merge_base":"c",\
"patch":"diff --git a/x b/x\\n--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-a\\n+b\\n",\
"truncated":true},"exit_code":0}
"""))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
model.base = "main"
model.head = "feature"
await model.compare()
#expect(model.truncated)
}
@Test func comparingARefWithItselfIsEmptyNotFailed() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: """
{"protocol_version":1,"data":{"base":"a","head":"a","merge_base":"a",\
"patch":"","truncated":false},"exit_code":0}
"""))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
model.base = "main"
model.head = "main"
await model.compare()
guard case .empty = model.state else {
Testing.Issue.record("expected empty, got \(model.state)")
return
}
}
@Test func aBlankRefComparesNothing() async throws {
let (client, stub) = try makeClient()
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
model.base = "main"
model.head = " "
await model.compare()
#expect(stub.seen.isEmpty)
}
@Test func refsComeFromRepoRefs() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: """
{"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
{"name":"feature","sha":"b"}],"tags":[{"name":"v1","sha":"c"}]},"exit_code":0}
"""))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
await model.loadRefs()
#expect(model.refs?.branches.map(\.name) == ["main", "feature"])
#expect(model.refs?.tags.map(\.name) == ["v1"])
#expect(argvFrom(try #require(stub.seen.last).url)
== ["repo", "refs", "krz/gitbay"])
}
/// The lesson from the retarget picker: a failed ref load must not
/// look like a repository with no branches.
@Test func aFailedRefLoadIsDistinguishableFromNoBranches() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: """
{"protocol_version":1,"error":"denied","exit_code":4}
"""))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
await model.loadRefs()
#expect(model.refs == nil)
#expect(model.refsError?.isEmpty == false)
}
@Test func aFailedCompareIsTheScreensState() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: """
{"protocol_version":1,"error":"unknown revision","exit_code":1}
"""))
let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
model.base = "main"
model.head = "nope"
await model.compare()
guard case .failed = model.state else {
Testing.Issue.record("expected failed, got \(model.state)")
return
}
}
}
- Step 2: Run to verify failure.
- Step 3: Implement.
RepoDiffwithCodingKeysmappingmerge_base.CompareViewModelholdingclient,repoPath, the two ref strings,state,truncated,mergeBase,refs: RepoRefs?,refsError: String?.compare()guards on both refs being non-blank, reads, parses withUnifiedDiff.parse(diff.patch), and sets.emptywhen the parsed diff has no files. - Step 4: Run the tests.
- Step 5: Commit —
git commit -m "Compare two refs through repo diff"
Task 2: The compare screen
Files: Create gitbay/Views/Repos/CompareView.swift; modify RepoRoute.swift, ContentView.swift, RefsView.swift
No unit tests — UI.
Reuse DiffFileSection(file:) with no model, exactly as CommitView.swift:71 does. Do not modify it.
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.
Three things the screen must say:
- When
truncated— a visible notice that the patch is incomplete.GBNoticeexists for this. Rendering a cut-short diff silently is the one way this screen misleads. - The merge base, so the user knows what the comparison is against —
repo diffreports it and a three-dot comparison against an unstated base is ambiguous. - A failed ref load is not "no branches" — render
refsErrordistinctly from an empty ref list, the same way MR 4's retarget picker does.
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).
- Step 1: Route case + ContentView destination
- Step 2:
CompareViewwith the two pickers and the patch - Step 3: Entry point in
RefsView - Step 4: Build and run the full suite. No drop.
- Step 5: Commit —
git commit -m "Compare screen with two ref pickers"
Task 3: Flip the parity row and open the merge request
- Step 1: In the Repositories table,
compare two refsgoes toyesfor iOS. Touch no other row.
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.
- Step 2: Run the full suite; record the real number.
- Step 3: Open the merge request.
Notes for whoever executes this
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.
Base comes first. repo diff <owner/name> <base> <head> — swapping them returns the opposite diff with no error at all.
truncated must reach the screen. A partial patch that looks whole is worse than an error.