Commit 34ae1d1cb0

34ae1d1cb016b8962722052d02178f07ea7068fe

parent: ba72ebf234

Unsigned

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

Rename a repository (!90)

repo rename (krz/gitbay#190) from a settings row behind an alert with a clone-URL note. On success the screen offers the renamed repository in a Renamed section as a value link; an item-based push from inside the stack popped back to the dead screen, which is why it is a link. The live suite renames the scratch repo to ui-smoke-2 and back, and cleanup deletes both names. The repo screen's Settings row carries repo-settings-link so a stacked screen's title cannot match a generic lookup.

Layout: unified · split

gitbay/Repos/RepoSettingsViewModel.swift +24
@@ -138,6 +138,30 @@ final class RepoSettingsViewModel {
138 await perform(["repo", "settings", "default-branch", repoPath, branch]) 138 await perform(["repo", "settings", "default-branch", repoPath, branch])
139 } 139 }
140 140
141 nonisolated private struct Renamed: Decodable, Sendable {
142 let repo: String
143 }
144
145 /// `repo rename <owner/name> <new-name>`: the row and the directory
146 /// move together and clone URLs change. Returns the new path; the
147 /// caller navigates there, since this screen's path is now gone.
148 func rename(to name: String) async -> String? {
149 let name = name.trimmingCharacters(in: .whitespaces)
150 guard !name.isEmpty else { return nil }
151 working = true
152 actionError = nil
153 defer { working = false }
154 do {
155 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self)
156 return renamed?.repo
157 } catch let error as GitbayError {
158 actionError = error.userFacingMessage
159 } catch {
160 actionError = GitbayError.transport(error).userFacingMessage
161 }
162 return nil
163 }
164
141 func loadBranches() async { 165 func loadBranches() async {
142 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) 166 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
143 branches = refs?.branches.map(\.name) ?? [] 167 branches = refs?.branches.map(\.name) ?? []
gitbay/Views/Repos/RepoSettingsView.swift +53
@@ -10,6 +10,11 @@ struct RepoSettingsView: View {
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 newTagGlob = ""
13 @State private var renaming = false
14 @State private var newName = ""
15 /// Set once a rename succeeds: this screen's repository is gone and
16 /// the renamed one is a value push away.
17 @State private var renamedTo: String?
13 @State private var loadedOnce = false 18 @State private var loadedOnce = false
14 @State private var removingTopic: String? 19 @State private var removingTopic: String?
15 @State private var newGrantUser = "" 20 @State private var newGrantUser = ""
@@ -22,6 +27,19 @@ struct RepoSettingsView: View {
22 27
23 var body: some View { 28 var body: some View {
24 List { 29 List {
30 if let renamedTo {
31 Section {
32 NavigationLink(value: RepoRoute.repo(renamedTo)) {
33 Label("Open \(renamedTo)", systemImage: "arrow.right.circle")
34 .font(.gbSans(.subheadline))
35 }
36 .accessibilityIdentifier("settings-renamed-link")
37 } header: {
38 Text("Renamed")
39 } footer: {
40 Text("This screen's repository moved; nothing here applies to it any more.")
41 }
42 }
25 if let loaded = model.state.value { 43 if let loaded = model.state.value {
26 if let error = model.actionError { 44 if let error = model.actionError {
27 Section { 45 Section {
@@ -39,6 +57,7 @@ struct RepoSettingsView: View {
39 accessSection 57 accessSection
40 webhooksSection 58 webhooksSection
41 depsSection() 59 depsSection()
60 renameSection
42 } 61 }
43 } 62 }
44 .overlay { LoadStateOverlay(state: model.state) } 63 .overlay { LoadStateOverlay(state: model.state) }
@@ -76,6 +95,22 @@ struct RepoSettingsView: View {
76 } 95 }
77 Button("Cancel", role: .cancel) {} 96 Button("Cancel", role: .cancel) {}
78 } 97 }
98 .alert("Rename \(model.repoPath)", isPresented: $renaming) {
99 TextField("New name", text: $newName)
100 .autocorrectionDisabled()
101 .textInputAutocapitalization(.never)
102 Button("Rename") {
103 Task {
104 if let path = await model.rename(to: newName) {
105 renamedTo = path
106 }
107 }
108 }
109 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
110 Button("Cancel", role: .cancel) {}
111 } message: {
112 Text("Clone URLs change.")
113 }
79 .confirmationDialog( 114 .confirmationDialog(
80 "Remove topic \(removingTopic ?? "")?", 115 "Remove topic \(removingTopic ?? "")?",
81 isPresented: Binding( 116 isPresented: Binding(
@@ -403,6 +438,24 @@ struct RepoSettingsView: View {
403 } 438 }
404 } 439 }
405 440
441 /// `repo rename`. Delete and transfer stay SSH-only by design; a
442 /// rename keeps the owner and is routine for someone with many repos.
443 private var renameSection: some View {
444 Section {
445 Button {
446 newName = String(model.repoPath.split(separator: "/").last ?? "")
447 renaming = true
448 } label: {
449 Label("Rename repository", systemImage: "pencil")
450 .font(.gbSans(.subheadline))
451 }
452 .disabled(model.working)
453 .accessibilityIdentifier("settings-rename-button")
454 } footer: {
455 Text("The owner stays; the name changes, and so do the clone URLs.")
456 }
457 }
458
406 private func depsSection() -> some View { 459 private func depsSection() -> some View {
407 Section { 460 Section {
408 if let deps = model.deps { 461 if let deps = model.deps {
gitbay/Views/Repos/RepoView.swift +1
@@ -78,6 +78,7 @@ struct RepoView: View {
78 NavigationLink(value: RepoRoute.settings(repo: path)) { 78 NavigationLink(value: RepoRoute.settings(repo: path)) {
79 Label("Settings", systemImage: "gearshape") 79 Label("Settings", systemImage: "gearshape")
80 } 80 }
81 .accessibilityIdentifier("repo-settings-link")
81 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) { 82 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
82 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle") 83 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
83 } 84 }
gitbayTests/RepoManagementTests.swift +23
@@ -122,6 +122,29 @@ struct RepoSettingsViewModelTests {
122 ]) 122 ])
123 } 123 }
124 124
125 @Test func renameSendsTheNewNameAndReturnsTheNewPath() async throws {
126 let (model, stub) = try await loadedModel()
127 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"repo":"krz/forge","was":"krz/gitbay"},"exit_code":0}"#, match: "cmd"))
128
129 let renamed = await model.rename(to: " forge ")
130
131 #expect(renamed == "krz/forge")
132 let write = try #require(stub.seen.first { $0.method == "POST" })
133 #expect(try argvOf(write) == ["repo", "rename", "krz/gitbay", "forge"])
134 // The old path is gone; nothing reloads it.
135 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
136 }
137
138 @Test func aRefusedRenameSurfacesAndReturnsNil() async throws {
139 let (model, stub) = try await loadedModel()
140 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "cmd"))
141
142 let renamed = await model.rename(to: "forge")
143
144 #expect(renamed == nil)
145 #expect(model.actionError == "admin access required")
146 }
147
125 @Test func branchesLoadForTheDefaultBranchPicker() async throws { 148 @Test func branchesLoadForTheDefaultBranchPicker() async throws {
126 let (model, stub) = try await loadedModel() 149 let (model, stub) = try await loadedModel()
127 stub.enqueue(.init(status: 200, json: """ 150 stub.enqueue(.init(status: 200, json: """
gitbayUITests/LiveSmokeUITests.swift +56 −9
@@ -66,6 +66,8 @@ final class LiveSmokeUITests: XCTestCase {
66 /// outcome we want anyway. 66 /// outcome we want anyway.
67 private func deleteScratchRepo() { 67 private func deleteScratchRepo() {
68 _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"]) 68 _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"])
69 // The rename round-trip's halfway name, in case a run died there.
70 _ = runCommand(["repo", "delete", Self.scratchRepo + "-2", "--yes"])
69 } 71 }
70 72
71 /// The issue the editing flow works on. It is a fixture, not a real 73 /// The issue the editing flow works on. It is a fixture, not a real
@@ -433,9 +435,9 @@ extension LiveSmokeUITests {
433 // lazy, so find the new repo through the filter. 435 // lazy, so find the new repo through the filter.
434 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15), 436 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
435 "create sheet did not dismiss") 437 "create sheet did not dismiss")
436 let search = app.searchFields.firstMatch 438 let createSearch = app.searchFields.firstMatch
437 XCTAssertTrue(search.waitForExistence(timeout: 10)) 439 XCTAssertTrue(createSearch.waitForExistence(timeout: 10))
438 focusAndType(search, "ui-smoke") 440 focusAndType(createSearch, "ui-smoke")
439 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch 441 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
440 .waitForExistence(timeout: 15), "created repo not in the list") 442 .waitForExistence(timeout: 15), "created repo not in the list")
441 443
@@ -482,13 +484,53 @@ extension LiveSmokeUITests {
482 confirmHookRemove.tap() 484 confirmHookRemove.tap()
483 XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") 485 XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
484 back() // webhooks -> settings 486 back() // webhooks -> settings
485 back() // settings -> repo 487
486 back() // repo -> list 488 // --- rename the scratch repo there and back ---
489 // Each rename pushes the renamed repository; the round-trip ends
490 // on cmc/ui-smoke again, several screens deep.
491 var firstRename = true
492 let rename: (String, String) -> Void = { newName, expectedPath in
493 if firstRename {
494 firstRename = false // already on the settings screen
495 } else {
496 // Fresh repo screen: scroll to the top so no row hides
497 // under the navigation bar, then reach Settings.
498 self.app.swipeDown()
499 let settingsLink = self.app.descendants(matching: .any).matching(identifier: "repo-settings-link").firstMatch
500 XCTAssertTrue(self.scrollTo(settingsLink), "settings link not reachable")
501 settingsLink.tap()
502 }
503 let renameButton = self.app.descendants(matching: .any)
504 .matching(identifier: "settings-rename-button").firstMatch
505 XCTAssertTrue(self.scrollTo(renameButton, swipes: 10), "rename button not reachable")
506 renameButton.tap()
507 let field = self.app.textFields["New name"].firstMatch
508 XCTAssertTrue(field.waitForExistence(timeout: 5), "rename alert did not open")
509 field.tap()
510 // Prefilled with the current name; clear it before typing.
511 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName)
512 self.app.buttons["Rename"].firstMatch.tap()
513 // The settings screen offers the renamed repository at the
514 // top; the list is scrolled to its end, so go back up first.
515 let renamedLink = self.app.descendants(matching: .any)
516 .matching(identifier: "settings-renamed-link").firstMatch
517 sleep(2)
518 for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() }
519 XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land")
520 renamedLink.tap()
521 XCTAssertTrue(self.app.staticTexts[expectedPath].firstMatch.waitForExistence(timeout: 15),
522 "renamed repo did not open as \(expectedPath)")
523 }
524 rename("ui-smoke-2", "cmc/ui-smoke-2")
525 rename("ui-smoke", "cmc/ui-smoke")
487 526
488 // --- pin / unpin round-trip on krz/gitbay-ios --- 527 // --- pin / unpin round-trip on krz/gitbay-ios ---
489 // Reuse the open search to get there. 528 // Start the list fresh; the rename left the stack several deep.
490 let clear = search.buttons.firstMatch 529 app.terminate()
491 if clear.exists { clear.tap() } 530 app.launch()
531 selectTab("Repositories")
532 let search = app.searchFields.firstMatch
533 XCTAssertTrue(search.waitForExistence(timeout: 10))
492 focusAndType(search, "krz/gitbay-ios") 534 focusAndType(search, "krz/gitbay-ios")
493 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch 535 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
494 XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) 536 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
@@ -543,7 +585,8 @@ extension LiveSmokeUITests {
543 // --- settings: topic and merge-rule round-trips --- 585 // --- settings: topic and merge-rule round-trips ---
544 // The links section can sit below the README fold, and the row's 586 // The links section can sit below the README fold, and the row's
545 // label is not always exposed as a static text. 587 // label is not always exposed as a static text.
546 let settingsLink = app.descendants(matching: .any)["Settings"].firstMatch 588 let settingsLink = app.descendants(matching: .any)
589 .matching(identifier: "repo-settings-link").firstMatch
547 XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable") 590 XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
548 settingsLink.tap() 591 settingsLink.tap()
549 let addTopic = app.descendants(matching: .any) 592 let addTopic = app.descendants(matching: .any)
@@ -633,6 +676,9 @@ extension LiveSmokeUITests {
633 676
634 677
635 back() // settings -> repo 678 back() // settings -> repo
679 // The repo screen keeps the scroll that reached Settings, which
680 // can leave Builds under the navigation bar.
681 app.swipeDown()
636 682
637 // --- nothing to trigger without a job file --- 683 // --- nothing to trigger without a job file ---
638 // This repo has no .gitbay/ci.yml, so `build jobs` returns none 684 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
@@ -649,6 +695,7 @@ extension LiveSmokeUITests {
649 .firstMatch 695 .firstMatch
650 XCTAssertFalse(anyJob.waitForExistence(timeout: 3), 696 XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
651 "a repo with no job file offered a job to trigger") 697 "a repo with no job file offered a job to trigger")
698
652 } 699 }
653 700
654 private func waitForValue(_ element: XCUIElement, _ value: String, 701 private func waitForValue(_ element: XCUIElement, _ value: String,