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 {
138138 await perform(["repo", "settings", "default-branch", repoPath, branch])
139139 }
140140
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
141165 func loadBranches() async {
142166 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
143167 branches = refs?.branches.map(\.name) ?? []
gitbay/Views/Repos/RepoSettingsView.swift +53
@@ -10,6 +10,11 @@ struct RepoSettingsView: View {
1010 @State private var newTopic = ""
1111 @State private var newBranch = ""
1212 @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?
1318 @State private var loadedOnce = false
1419 @State private var removingTopic: String?
1520 @State private var newGrantUser = ""
@@ -22,6 +27,19 @@ struct RepoSettingsView: View {
2227
2328 var body: some View {
2429 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 }
2543 if let loaded = model.state.value {
2644 if let error = model.actionError {
2745 Section {
@@ -39,6 +57,7 @@ struct RepoSettingsView: View {
3957 accessSection
4058 webhooksSection
4159 depsSection()
60 renameSection
4261 }
4362 }
4463 .overlay { LoadStateOverlay(state: model.state) }
@@ -76,6 +95,22 @@ struct RepoSettingsView: View {
7695 }
7796 Button("Cancel", role: .cancel) {}
7897 }
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 }
79114 .confirmationDialog(
80115 "Remove topic \(removingTopic ?? "")?",
81116 isPresented: Binding(
@@ -403,6 +438,24 @@ struct RepoSettingsView: View {
403438 }
404439 }
405440
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
406459 private func depsSection() -> some View {
407460 Section {
408461 if let deps = model.deps {
gitbay/Views/Repos/RepoView.swift +1
@@ -78,6 +78,7 @@ struct RepoView: View {
7878 NavigationLink(value: RepoRoute.settings(repo: path)) {
7979 Label("Settings", systemImage: "gearshape")
8080 }
81 .accessibilityIdentifier("repo-settings-link")
8182 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
8283 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
8384 }
gitbayTests/RepoManagementTests.swift +23
@@ -122,6 +122,29 @@ struct RepoSettingsViewModelTests {
122122 ])
123123 }
124124
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
125148 @Test func branchesLoadForTheDefaultBranchPicker() async throws {
126149 let (model, stub) = try await loadedModel()
127150 stub.enqueue(.init(status: 200, json: """
gitbayUITests/LiveSmokeUITests.swift +56 −9
@@ -66,6 +66,8 @@ final class LiveSmokeUITests: XCTestCase {
6666 /// outcome we want anyway.
6767 private func deleteScratchRepo() {
6868 _ = 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"])
6971 }
7072
7173 /// The issue the editing flow works on. It is a fixture, not a real
@@ -433,9 +435,9 @@ extension LiveSmokeUITests {
433435 // lazy, so find the new repo through the filter.
434436 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
435437 "create sheet did not dismiss")
436 let search = app.searchFields.firstMatch
437 XCTAssertTrue(search.waitForExistence(timeout: 10))
438 focusAndType(search, "ui-smoke")
438 let createSearch = app.searchFields.firstMatch
439 XCTAssertTrue(createSearch.waitForExistence(timeout: 10))
440 focusAndType(createSearch, "ui-smoke")
439441 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
440442 .waitForExistence(timeout: 15), "created repo not in the list")
441443
@@ -482,13 +484,53 @@ extension LiveSmokeUITests {
482484 confirmHookRemove.tap()
483485 XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
484486 back() // webhooks -> settings
485 back() // settings -> repo
486 back() // repo -> list
487
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")
487526
488527 // --- pin / unpin round-trip on krz/gitbay-ios ---
489 // Reuse the open search to get there.
490 let clear = search.buttons.firstMatch
491 if clear.exists { clear.tap() }
528 // Start the list fresh; the rename left the stack several deep.
529 app.terminate()
530 app.launch()
531 selectTab("Repositories")
532 let search = app.searchFields.firstMatch
533 XCTAssertTrue(search.waitForExistence(timeout: 10))
492534 focusAndType(search, "krz/gitbay-ios")
493535 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
494536 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
@@ -543,7 +585,8 @@ extension LiveSmokeUITests {
543585 // --- settings: topic and merge-rule round-trips ---
544586 // The links section can sit below the README fold, and the row's
545587 // 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
547590 XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
548591 settingsLink.tap()
549592 let addTopic = app.descendants(matching: .any)
@@ -633,6 +676,9 @@ extension LiveSmokeUITests {
633676
634677
635678 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()
636682
637683 // --- nothing to trigger without a job file ---
638684 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
@@ -649,6 +695,7 @@ extension LiveSmokeUITests {
649695 .firstMatch
650696 XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
651697 "a repo with no job file offered a job to trigger")
698
652699 }
653700
654701 private func waitForValue(_ element: XCUIElement, _ value: String,