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
4/// Every field is omitted at its zero value, so absent means default. 4/// Every field is omitted at its zero value, so absent means default.
5nonisolated struct RepoSettings: Decodable, Sendable, Hashable { 5nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
6 let protectedBranches: [String]? 6 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]?
7 let requireSignedCommits: Bool? 10 let requireSignedCommits: Bool?
8 let requireChecks: Bool? 11 let requireChecks: Bool?
9 let requireApprovals: Int? 12 let requireApprovals: Int?
10 let requireResolved: Bool? 13 let requireResolved: Bool?
11 let requireCodeowners: Bool? 14 let requireCodeowners: Bool?
15 /// Protected branches take changes through merge requests only (v1.16.0).
16 let requireMR: Bool?
12 let gitDaemon: Bool? 17 let gitDaemon: Bool?
13 let archived: Bool? 18 let archived: Bool?
14 let website: String? 19 let website: String?
15 20
16 enum CodingKeys: String, CodingKey { 21 enum CodingKeys: String, CodingKey {
17 case protectedBranches = "protected_branches" 22 case protectedBranches = "protected_branches"
23 case protectedTags = "protected_tags"
24 case requireMR = "require_mr"
18 case requireSignedCommits = "require_signed_commits" 25 case requireSignedCommits = "require_signed_commits"
19 case requireChecks = "require_checks" 26 case requireChecks = "require_checks"
20 case requireApprovals = "require_approvals" 27 case requireApprovals = "require_approvals"
@@ -25,6 +32,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
25 } 32 }
26 33
27 var branches: [String] { protectedBranches ?? [] } 34 var branches: [String] { protectedBranches ?? [] }
35 var tags: [String] { protectedTags ?? [] }
28} 36}
29 37
30/// One row of `repo access list`: an account and its effective role. 38/// One row of `repo access list`: an account and its effective role.
gitbay/Repos/RepoSettingsViewModel.swift +27
@@ -30,6 +30,9 @@ final class RepoSettingsViewModel {
30 private(set) var grants: [AccessGrant]? 30 private(set) var grants: [AccessGrant]?
31 private(set) var grantsError: String? 31 private(set) var grantsError: String?
32 32
33 /// Branch names from `repo refs`, for the default-branch picker.
34 private(set) var branches: [String] = []
35
33 private let client: GitbayClient 36 private let client: GitbayClient
34 let repoPath: String 37 let repoPath: String
35 38
@@ -116,6 +119,30 @@ final class RepoSettingsViewModel {
116 await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"]) 119 await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"])
117 } 120 }
118 121
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
119 func loadGrants() async { 146 func loadGrants() async {
120 grantsError = nil 147 grantsError = nil
121 grants = nil 148 grants = nil
gitbay/Views/Repos/RepoSettingsView.swift +78 −1
@@ -9,6 +9,7 @@ struct RepoSettingsView: View {
9 @State private var websiteText = "" 9 @State private var websiteText = ""
10 @State private var newTopic = "" 10 @State private var newTopic = ""
11 @State private var newBranch = "" 11 @State private var newBranch = ""
12 @State private var newTagGlob = ""
12 @State private var loadedOnce = false 13 @State private var loadedOnce = false
13 @State private var removingTopic: String? 14 @State private var removingTopic: String?
14 @State private var newGrantUser = "" 15 @State private var newGrantUser = ""
@@ -30,7 +31,9 @@ struct RepoSettingsView: View {
30 aboutSection(loaded) 31 aboutSection(loaded)
31 topicsSection(loaded) 32 topicsSection(loaded)
32 visibilitySection(loaded) 33 visibilitySection(loaded)
34 defaultBranchSection(loaded)
33 branchesSection(loaded) 35 branchesSection(loaded)
36 tagsSection(loaded)
34 mergeRulesSection(loaded) 37 mergeRulesSection(loaded)
35 daemonSection(loaded) 38 daemonSection(loaded)
36 accessSection 39 accessSection
@@ -51,10 +54,12 @@ struct RepoSettingsView: View {
51 } 54 }
52 .task { await model.loadDeps() } 55 .task { await model.loadDeps() }
53 .task { await model.loadGrants() } 56 .task { await model.loadGrants() }
57 .task { await model.loadBranches() }
54 .refreshable { 58 .refreshable {
55 await model.load() 59 await model.load()
56 await model.loadDeps() 60 await model.loadDeps()
57 await model.loadGrants() 61 await model.loadGrants()
62 await model.loadBranches()
58 } 63 }
59 .confirmationDialog( 64 .confirmationDialog(
60 "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?", 65 "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?",
@@ -209,10 +214,82 @@ struct RepoSettingsView: View {
209 } 214 }
210 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working) 215 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
211 } 216 }
217 toggle("Merge requests only", loaded.settings.requireMR ?? false) {
218 await model.setRequireMR($0)
219 }
212 } header: { 220 } header: {
213 Text("Protected branches") 221 Text("Protected branches")
214 } footer: { 222 } 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*.")
216 } 293 }
217 } 294 }
218 295
gitbayTests/RepoManagementTests.swift +26 −2
@@ -21,7 +21,8 @@ private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
21 21
22private let settingsShowJSON = """ 22private let settingsShowJSON = """
23 {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\ 23 {"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}
25 """ 26 """
26 27
27private let repoShowJSON = """ 28private let repoShowJSON = """
@@ -51,6 +52,8 @@ struct RepoSettingsViewModelTests {
51 // Absent means default, not unknown. 52 // Absent means default, not unknown.
52 #expect(loaded.settings.requireChecks == nil) 53 #expect(loaded.settings.requireChecks == nil)
53 #expect(loaded.settings.requireCodeowners == true) 54 #expect(loaded.settings.requireCodeowners == true)
55 #expect(loaded.settings.requireMR == true)
56 #expect(loaded.settings.tags == ["v*"])
54 #expect(loaded.detail.topics == ["git"]) 57 #expect(loaded.detail.topics == ["git"])
55 } 58 }
56 59
@@ -73,7 +76,7 @@ struct RepoSettingsViewModelTests {
73 76
74 @Test func everyKnobSendsItsOwnCommand() async throws { 77 @Test func everyKnobSendsItsOwnCommand() async throws {
75 let (model, stub) = try await loadedModel() 78 let (model, stub) = try await loadedModel()
76 for _ in 0..<13 { 79 for _ in 0..<17 {
77 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) 80 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
78 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings")) 81 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
79 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) 82 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
@@ -90,6 +93,10 @@ struct RepoSettingsViewModelTests {
90 await model.setRequireChecks(true) 93 await model.setRequireChecks(true)
91 await model.setRequireSigned(true) 94 await model.setRequireSigned(true)
92 await model.setRequireCodeowners(true) 95 await model.setRequireCodeowners(true)
96 await model.setRequireMR(true)
97 await model.protectTag("v*")
98 await model.unprotectTag("v*")
99 await model.setDefaultBranch("release")
93 await model.addTopic("ios") 100 await model.addTopic("ios")
94 await model.removeTopic("git") 101 await model.removeTopic("git")
95 102
@@ -106,11 +113,28 @@ struct RepoSettingsViewModelTests {
106 ["repo", "settings", "require-checks", "krz/gitbay", "on"], 113 ["repo", "settings", "require-checks", "krz/gitbay", "on"],
107 ["repo", "settings", "require-signed", "krz/gitbay", "on"], 114 ["repo", "settings", "require-signed", "krz/gitbay", "on"],
108 ["repo", "settings", "require-codeowners", "krz/gitbay", "on"], 115 ["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"],
109 ["repo", "topics", "add", "krz/gitbay", "ios"], 120 ["repo", "topics", "add", "krz/gitbay", "ios"],
110 ["repo", "topics", "remove", "krz/gitbay", "git"], 121 ["repo", "topics", "remove", "krz/gitbay", "git"],
111 ]) 122 ])
112 } 123 }
113 124
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
114 @Test func accessGrantsLoadSeparately() async throws { 138 @Test func accessGrantsLoadSeparately() async throws {
115 let (model, stub) = try await loadedModel() 139 let (model, stub) = try await loadedModel()
116 stub.enqueue(.init(status: 200, json: """ 140 stub.enqueue(.init(status: 200, json: """
gitbayUITests/LiveSmokeUITests.swift +32
@@ -563,6 +563,37 @@ extension LiveSmokeUITests {
563 confirmRemove.tap() 563 confirmRemove.tap()
564 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") 564 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
565 565
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.
566 let resolved = app.switches["Require threads resolved"].firstMatch 597 let resolved = app.switches["Require threads resolved"].firstMatch
567 // The merge-requirements section sits below the fold, and a List 598 // The merge-requirements section sits below the fold, and a List
568 // does not build rows it has not shown, so it must be scrolled 599 // does not build rows it has not shown, so it must be scrolled
@@ -600,6 +631,7 @@ extension LiveSmokeUITests {
600 flipCodeowners() 631 flipCodeowners()
601 XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off") 632 XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
602 633
634
603 back() // settings -> repo 635 back() // settings -> repo
604 636
605 // --- nothing to trigger without a job file --- 637 // --- nothing to trigger without a job file ---