Commit 0a5413dcd9

0a5413dcd9e784bc473af240125ce8afcb361335

parent: 7dac615a6f

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-07 00:22 UTC

Fix OrgViewModel.rename leaving state failed after success

rename(to:) routed through the shared perform(_:), whose automatic
reload always read orgName — a let fixed at init, never updated. A
successful rename immediately reloaded the old, now-renamed org and
drove state to .failed on the same call that returned true.

rename(to:) now runs its own write/reload sequence instead of using
perform: orgName moves to the new value only after the write actually
succeeds, and the reload that follows targets it. A rejected rename
leaves orgName untouched.

Rewrote renameSendsOldThenNew, which enqueued 2 stubs for the 3-request
flow (POST plus load()'s two concurrent GETs) and never asserted on
state, so it passed against the bug. It now provisions all three
requests and checks the reload's argv, not just the absence of an
error.

Layout: unified · split

gitbay/Orgs/OrgViewModels.swift +21 −3
@@ -31,7 +31,7 @@ final class OrgViewModel {
3131 private(set) var working = false
3232
3333 private let client: GitbayClient
34 let orgName: String
34 private(set) var orgName: String
3535
3636 init(client: GitbayClient, orgName: String) {
3737 self.client = client
@@ -69,11 +69,29 @@ final class OrgViewModel {
6969
7070 /// `org rename <old> <new>` — both positional, old first. Swapping
7171 /// them renames the wrong way with no error at all.
72 ///
73 /// Doesn't go through `perform`: its automatic reload would still
74 /// query the old, now-renamed org. `orgName` only moves to the new
75 /// name once the rename itself has actually succeeded, and the
76 /// reload that follows targets that new name.
7277 func rename(to newName: String) async -> Bool {
7378 let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
7479 guard !newName.isEmpty else { return false }
75 await perform(["org", "rename", orgName, newName])
76 return actionError == nil
80 working = true
81 actionError = nil
82 defer { working = false }
83 do {
84 try await client.run(["org", "rename", orgName, newName])
85 orgName = newName
86 await load()
87 return true
88 } catch let error as GitbayError {
89 actionError = error.userFacingMessage
90 return false
91 } catch {
92 actionError = GitbayError.transport(error).userFacingMessage
93 return false
94 }
7795 }
7896
7997 func createTeam(_ team: String) async {
gitbayTests/SmallActionTests.swift +27 −3
@@ -81,15 +81,39 @@ struct OrgCreateRenameTests {
8181
8282 /// Both names are positional, OLD first. Swapping them renames the
8383 /// wrong way with no error at all.
84 @Test func renameSendsOldThenNew() async throws {
84 ///
85 /// A successful rename triggers `load()`, which fires two concurrent
86 /// GETs (`org show`, `org team list`) alongside the POST — all three
87 /// need a stub, or the reload fails and the model ends up showing an
88 /// error despite the rename having worked.
89 @Test func renameSendsOldThenNewAndReloadsTheNewName() async throws {
8590 let (client, stub) = try makeClient()
8691 let model = OrgViewModel(client: client, orgName: "krz")
8792 stub.enqueue(.init(status: 200, json: ok))
88 stub.enqueue(.init(status: 200, json: ok))
89 _ = await model.rename(to: "kerouac")
93 stub.enqueue(.init(status: 200, json: """
94 {"protocol_version":1,"data":{"org":"kerouac","members":[]},"exit_code":0}
95 """, match: "argv=show"))
96 stub.enqueue(.init(status: 200, json: """
97 {"protocol_version":1,"data":[],"exit_code":0}
98 """, match: "argv=team"))
99 let renamed = await model.rename(to: "kerouac")
90100
101 #expect(renamed)
91102 let write = try #require(stub.seen.first { $0.method == "POST" })
92103 #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"])
104
105 #expect(model.actionError == nil)
106 guard case .loaded = model.state else {
107 Issue.record("expected .loaded after a successful rename, got \(model.state)")
108 return
109 }
110 let reloads = try stub.seen.filter { $0.method == "GET" }.map { seen in
111 try #require(seen.url.query(percentEncoded: false)).components(separatedBy: "&")
112 }
113 for reload in reloads {
114 #expect(reload.contains("argv=kerouac"),
115 "expected the reload to target the new name, got \(reload)")
116 }
93117 }
94118
95119 @Test func aBlankRenameSendsNothing() async throws {