Commit 60cc6810d1

60cc6810d163822ffc1d5d22221cb58e828f7890

parent: c694ac101b

Verified · cmc

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

Fix merge dialog stack options, branch error clearing, draft toggle, and reload assertions

- Merge dialog offers Merge and Fast-forward on a stacked branch, not just Fast-forward
- loadBranches() clears branchesError before fetching
- MRCreateSheet exposes the draft toggle, making --draft reachable
- MR lifecycle action tests assert the reload GETs happen, and that a refusal skips them

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +1
@@ -139,6 +139,7 @@ final class MRDetailViewModel {
139139 /// secondary action, not the primary content `load()` refreshes.
140140 func loadBranches() async {
141141 guard branches == nil else { return }
142 branchesError = nil
142143 do {
143144 let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
144145 branches = refs.branches.map(\.name)
gitbay/Views/MRs/MRListView.swift +6
@@ -116,6 +116,12 @@ private struct MRCreateSheet: View {
116116 .autocorrectionDisabled()
117117 .accessibilityIdentifier("mr-body")
118118 }
119 Section {
120 Toggle("Draft", isOn: Bindable(model).draft)
121 .accessibilityIdentifier("mr-draft")
122 } footer: {
123 Text("A draft merge request is open but not asking for review: it won't merge and won't show up in a review queue.")
124 }
119125 if let error = model.errorMessage {
120126 Section {
121127 GBNotice(error)
gitbay/Views/MRs/MRView.swift +2 −1
@@ -65,6 +65,7 @@ struct MRView: View {
6565 .refreshable { await model.load() }
6666 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
6767 if let mr = model.state.value, !mr.stacked.isEmpty {
68 Button("Merge") { Task { await model.merge() } }
6869 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
6970 } else {
7071 Button("Merge") { Task { await model.merge() } }
@@ -75,7 +76,7 @@ struct MRView: View {
7576 Button("Cancel", role: .cancel) {}
7677 } message: {
7778 if let mr = model.state.value, !mr.stacked.isEmpty {
78 Text("Merge requests are stacked on this one. Only a fast-forward merge is offered — squash and rebase would rewrite the commits the stack builds on.")
79 Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.")
7980 } else {
8081 Text("The server enforces approvals, threads and checks — a refusal will say why.")
8182 }
gitbayTests/MRLifecycleTests.swift +15
@@ -121,18 +121,27 @@ struct MRLifecycleActionTests {
121121 """))
122122 }
123123
124 /// `load()` issues three GETs: `mr show`, `mr diff`, `mr threads`.
125 /// `loaded()` runs it once (3 GETs, the last two unstubbed and failed),
126 /// so a reload after a write brings the total to 6.
127 private func gets(_ box: StubProtocol.Box) -> Int {
128 box.seen.count { $0.method == "GET" }
129 }
130
124131 @Test func markingDraftAndReadyAreDifferentCommands() async throws {
125132 let (model, box) = try await loaded()
126133 ok(box)
127134 await model.setDraft(true)
128135 var write = try #require(box.seen.first { $0.method == "POST" })
129136 #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"])
137 #expect(gets(box) == 6)
130138
131139 let (model2, box2) = try await loaded()
132140 ok(box2)
133141 await model2.setDraft(false)
134142 write = try #require(box2.seen.first { $0.method == "POST" })
135143 #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"])
144 #expect(gets(box2) == 6)
136145 }
137146
138147 @Test func retargetPassesTheBranchPositionally() async throws {
@@ -141,6 +150,7 @@ struct MRLifecycleActionTests {
141150 await model.retarget(to: "release")
142151 let write = try #require(box.seen.first { $0.method == "POST" })
143152 #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"])
153 #expect(gets(box) == 6)
144154 }
145155
146156 /// `mr review request` is a THREE-word path. Two words posts a
@@ -152,6 +162,7 @@ struct MRLifecycleActionTests {
152162 let write = try #require(box.seen.first { $0.method == "POST" })
153163 #expect(try argvOf(write)
154164 == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"])
165 #expect(gets(box) == 6)
155166 }
156167
157168 @Test func removingAReviewRequestUsesRemoveNotAdd() async throws {
@@ -161,6 +172,7 @@ struct MRLifecycleActionTests {
161172 let write = try #require(box.seen.first { $0.method == "POST" })
162173 #expect(try argvOf(write)
163174 == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"])
175 #expect(gets(box) == 6)
164176 }
165177
166178 @Test func branchesComeFromRepoRefs() async throws {
@@ -182,6 +194,9 @@ struct MRLifecycleActionTests {
182194 await model.retarget(to: "release")
183195 #expect(model.actionError?.isEmpty == false)
184196 #expect(model.working == false)
197 // A refusal throws before `perform` reaches `load()`: still the
198 // 3 GETs from `loaded()`, none from a reload.
199 #expect(gets(box) == 3)
185200 }
186201}
187202