Commit ba72ebf234

ba72ebf2348304981618cb0cb10460f8ce1a1011

parent: d2860af98a

Unsigned

cmc <hello@cleberg.net> · 2026-09-08 03:22 UTC

Settings: default branch, merge-requests-only, protected tags (!89)

The three repo settings v1.16.0 added (krz/gitbay#189, #197, #201). A default-branch menu over the branches repo refs lists, since the server refuses a name it does not have; a merge-requests-only toggle beside the protected branches; and a protected-tags glob list beside them, each row with its own unprotect identifier so the live suite cannot hit the branch's. Live repo flow round-trips the toggle and a glob.

Layout: unified · split

gitbay/Repos/RepoSettings.swift +8
@@ -4,17 +4,24 @@ import Foundation
44/// Every field is omitted at its zero value, so absent means default.
55nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
66 let protectedBranches: [String]?
7 /// `path.Match` globs; a matching tag is created once and then
8 /// refuses moves and deletion (v1.16.0).
9 let protectedTags: [String]?
710 let requireSignedCommits: Bool?
811 let requireChecks: Bool?
912 let requireApprovals: Int?
1013 let requireResolved: Bool?
1114 let requireCodeowners: Bool?
15 /// Protected branches take changes through merge requests only (v1.16.0).
16 let requireMR: Bool?
1217 let gitDaemon: Bool?
1318 let archived: Bool?
1419 let website: String?
1520
1621 enum CodingKeys: String, CodingKey {
1722 case protectedBranches = "protected_branches"
23 case protectedTags = "protected_tags"
24 case requireMR = "require_mr"
1825 case requireSignedCommits = "require_signed_commits"
1926 case requireChecks = "require_checks"
2027 case requireApprovals = "require_approvals"
@@ -25,6 +32,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
2532 }
2633
2734 var branches: [String] { protectedBranches ?? [] }
35 var tags: [String] { protectedTags ?? [] }
2836}
2937
3038/// One row of `repo access list`: an account and its effective role.
gitbay/Repos/RepoSettingsViewModel.swift +27
@@ -30,6 +30,9 @@ final class RepoSettingsViewModel {
3030 private(set) var grants: [AccessGrant]?
3131 private(set) var grantsError: String?
3232
33 /// Branch names from `repo refs`, for the default-branch picker.
34 private(set) var branches: [String] = []
35
3336 private let client: GitbayClient
3437 let repoPath: String
3538
@@ -116,6 +119,30 @@ final class RepoSettingsViewModel {
116119 await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"])
117120 }
118121
122 func setRequireMR(_ on: Bool) async {
123 await perform(["repo", "settings", "require-mr", repoPath, on ? "on" : "off"])
124 }
125
126 func protectTag(_ glob: String) async {
127 await perform(["repo", "settings", "protect-tag", repoPath, glob])
128 }
129
130 func unprotectTag(_ glob: String) async {
131 await perform(["repo", "settings", "unprotect-tag", repoPath, glob])
132 }
133
134 /// `repo settings default-branch`: moves HEAD and the record. The
135 /// server refuses a branch that does not exist, so the picker only
136 /// offers what `repo refs` listed.
137 func setDefaultBranch(_ branch: String) async {
138 await perform(["repo", "settings", "default-branch", repoPath, branch])
139 }
140
141 func loadBranches() async {
142 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
143 branches = refs?.branches.map(\.name) ?? []
144 }
145
119146 func loadGrants() async {
120147 grantsError = nil
121148 grants = nil
gitbay/Views/Repos/RepoSettingsView.swift +78 −1
@@ -9,6 +9,7 @@ struct RepoSettingsView: View {
99 @State private var websiteText = ""
1010 @State private var newTopic = ""
1111 @State private var newBranch = ""
12 @State private var newTagGlob = ""
1213 @State private var loadedOnce = false
1314 @State private var removingTopic: String?
1415 @State private var newGrantUser = ""
@@ -30,7 +31,9 @@ struct RepoSettingsView: View {
3031 aboutSection(loaded)
3132 topicsSection(loaded)
3233 visibilitySection(loaded)
34 defaultBranchSection(loaded)
3335 branchesSection(loaded)
36 tagsSection(loaded)
3437 mergeRulesSection(loaded)
3538 daemonSection(loaded)
3639 accessSection
@@ -51,10 +54,12 @@ struct RepoSettingsView: View {
5154 }
5255 .task { await model.loadDeps() }
5356 .task { await model.loadGrants() }
57 .task { await model.loadBranches() }
5458 .refreshable {
5559 await model.load()
5660 await model.loadDeps()
5761 await model.loadGrants()
62 await model.loadBranches()
5863 }
5964 .confirmationDialog(
6065 "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?",
@@ -209,10 +214,82 @@ struct RepoSettingsView: View {
209214 }
210215 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
211216 }
217 toggle("Merge requests only", loaded.settings.requireMR ?? false) {
218 await model.setRequireMR($0)
219 }
212220 } header: {
213221 Text("Protected branches")
214222 } footer: {
215 Text("Protected branches refuse force pushes and deletion.")
223 Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; mr merge becomes their only writer.")
224 }
225 }
226
227 /// `repo settings default-branch`: a menu over the branches the
228 /// server listed, since it refuses a name that does not exist.
229 private func defaultBranchSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
230 Section {
231 Menu {
232 ForEach(model.branches, id: \.self) { branch in
233 Button(branch) {
234 if branch != loaded.detail.defaultBranch {
235 Task { await model.setDefaultBranch(branch) }
236 }
237 }
238 }
239 } label: {
240 HStack {
241 Label(loaded.detail.defaultBranch, systemImage: "arrow.triangle.branch")
242 .font(.gbSans(.subheadline))
243 Spacer()
244 Image(systemName: "chevron.up.chevron.down")
245 .font(.gbSans(.caption))
246 .foregroundStyle(.secondary)
247 }
248 }
249 .disabled(model.working || model.branches.isEmpty)
250 .accessibilityIdentifier("settings-default-branch-menu")
251 } header: {
252 Text("Default branch")
253 } footer: {
254 Text("What a clone checks out, and what merge requests target by default.")
255 }
256 }
257
258 /// `repo settings protect-tag` / `unprotect-tag`: globs, path.Match style.
259 private func tagsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
260 Section {
261 ForEach(loaded.settings.tags, id: \.self) { glob in
262 HStack {
263 Label(glob, systemImage: "tag")
264 .font(.gbMono(.caption))
265 Spacer()
266 Button("Unprotect") {
267 Task { await model.unprotectTag(glob) }
268 }
269 .font(.gbSans(.caption))
270 .disabled(model.working)
271 .accessibilityIdentifier("settings-unprotect-tag-\(glob)")
272 }
273 }
274 HStack {
275 TextField("Protect tags matching", text: $newTagGlob)
276 .autocorrectionDisabled()
277 .textInputAutocapitalization(.never)
278 .accessibilityIdentifier("settings-protect-tag")
279 Button {
280 let glob = newTagGlob.trimmingCharacters(in: .whitespaces)
281 newTagGlob = ""
282 Task { await model.protectTag(glob) }
283 } label: {
284 Image(systemName: "plus.circle.fill")
285 }
286 .disabled(newTagGlob.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
287 .accessibilityIdentifier("settings-protect-tag-submit")
288 }
289 } header: {
290 Text("Protected tags")
291 } footer: {
292 Text("A matching tag is created once and then refuses moves and deletion. Globs such as v*.")
216293 }
217294 }
218295
gitbayTests/RepoManagementTests.swift +26 −2
@@ -21,7 +21,8 @@ private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
2121
2222private let settingsShowJSON = """
2323 {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\
24 "require_resolved":true,"require_codeowners":true,"website":"https://gitbay.org"},"exit_code":0}
24 "require_resolved":true,"require_codeowners":true,"require_mr":true,\
25 "protected_tags":["v*"],"website":"https://gitbay.org"},"exit_code":0}
2526 """
2627
2728private let repoShowJSON = """
@@ -51,6 +52,8 @@ struct RepoSettingsViewModelTests {
5152 // Absent means default, not unknown.
5253 #expect(loaded.settings.requireChecks == nil)
5354 #expect(loaded.settings.requireCodeowners == true)
55 #expect(loaded.settings.requireMR == true)
56 #expect(loaded.settings.tags == ["v*"])
5457 #expect(loaded.detail.topics == ["git"])
5558 }
5659
@@ -73,7 +76,7 @@ struct RepoSettingsViewModelTests {
7376
7477 @Test func everyKnobSendsItsOwnCommand() async throws {
7578 let (model, stub) = try await loadedModel()
76 for _ in 0..<13 {
79 for _ in 0..<17 {
7780 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
7881 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
7982 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
@@ -90,6 +93,10 @@ struct RepoSettingsViewModelTests {
9093 await model.setRequireChecks(true)
9194 await model.setRequireSigned(true)
9295 await model.setRequireCodeowners(true)
96 await model.setRequireMR(true)
97 await model.protectTag("v*")
98 await model.unprotectTag("v*")
99 await model.setDefaultBranch("release")
93100 await model.addTopic("ios")
94101 await model.removeTopic("git")
95102
@@ -106,11 +113,28 @@ struct RepoSettingsViewModelTests {
106113 ["repo", "settings", "require-checks", "krz/gitbay", "on"],
107114 ["repo", "settings", "require-signed", "krz/gitbay", "on"],
108115 ["repo", "settings", "require-codeowners", "krz/gitbay", "on"],
116 ["repo", "settings", "require-mr", "krz/gitbay", "on"],
117 ["repo", "settings", "protect-tag", "krz/gitbay", "v*"],
118 ["repo", "settings", "unprotect-tag", "krz/gitbay", "v*"],
119 ["repo", "settings", "default-branch", "krz/gitbay", "release"],
109120 ["repo", "topics", "add", "krz/gitbay", "ios"],
110121 ["repo", "topics", "remove", "krz/gitbay", "git"],
111122 ])
112123 }
113124
125 @Test func branchesLoadForTheDefaultBranchPicker() async throws {
126 let (model, stub) = try await loadedModel()
127 stub.enqueue(.init(status: 200, json: """
128 {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"aa"},\
129 {"name":"release","sha":"bb"}],"tags":[{"name":"v1","sha":"cc"}]},"exit_code":0}
130 """, match: "argv=refs"))
131
132 await model.loadBranches()
133
134 #expect(model.branches == ["main", "release"])
135 #expect(stub.seen.last?.url.query() == "argv=repo&argv=refs&argv=krz/gitbay")
136 }
137
114138 @Test func accessGrantsLoadSeparately() async throws {
115139 let (model, stub) = try await loadedModel()
116140 stub.enqueue(.init(status: 200, json: """
gitbayUITests/LiveSmokeUITests.swift +32
@@ -563,6 +563,37 @@ extension LiveSmokeUITests {
563563 confirmRemove.tap()
564564 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
565565
566 // Merge-requests-only sits with the protected branches.
567 let mergeOnly = app.switches["Merge requests only"].firstMatch
568 XCTAssertTrue(scrollTo(mergeOnly), "merge-only toggle not reachable")
569 let innerMergeOnly = mergeOnly.switches.firstMatch
570 let flipMergeOnly: () -> Void = {
571 if innerMergeOnly.exists && innerMergeOnly != mergeOnly {
572 innerMergeOnly.tap()
573 } else {
574 mergeOnly.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
575 }
576 }
577 flipMergeOnly()
578 XCTAssertTrue(waitForValue(mergeOnly, "1", timeout: 10), "merge-only toggle did not persist on")
579 flipMergeOnly()
580 XCTAssertTrue(waitForValue(mergeOnly, "0", timeout: 10), "merge-only toggle did not persist off")
581
582 // A tag glob is protected, listed, and unprotected.
583 let tagGlob = app.descendants(matching: .any)
584 .matching(identifier: "settings-protect-tag").firstMatch
585 XCTAssertTrue(scrollTo(tagGlob), "protected tags section not reachable")
586 focusAndType(tagGlob, "ui-smoke-*")
587 app.descendants(matching: .any).matching(identifier: "settings-protect-tag-submit")
588 .firstMatch.tap()
589 let globRow = app.staticTexts["ui-smoke-*"].firstMatch
590 XCTAssertTrue(globRow.waitForExistence(timeout: 10), "tag glob not listed")
591 // By identifier: the protected branch above has an Unprotect too.
592 app.descendants(matching: .any).matching(identifier: "settings-unprotect-tag-ui-smoke-*")
593 .firstMatch.tap()
594 XCTAssertTrue(waitForDisappearance(globRow, timeout: 10), "tag glob not unprotected")
595
596 // Merge rules sit below the protected branches and tags.
566597 let resolved = app.switches["Require threads resolved"].firstMatch
567598 // The merge-requirements section sits below the fold, and a List
568599 // does not build rows it has not shown, so it must be scrolled
@@ -600,6 +631,7 @@ extension LiveSmokeUITests {
600631 flipCodeowners()
601632 XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
602633
634
603635 back() // settings -> repo
604636
605637 // --- nothing to trigger without a job file ---