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 {
31 private(set) var working = false 31 private(set) var working = false
32 32
33 private let client: GitbayClient 33 private let client: GitbayClient
34 let orgName: String 34 private(set) var orgName: String
35 35
36 init(client: GitbayClient, orgName: String) { 36 init(client: GitbayClient, orgName: String) {
37 self.client = client 37 self.client = client
@@ -69,11 +69,29 @@ final class OrgViewModel {
69 69
70 /// `org rename <old> <new>` — both positional, old first. Swapping 70 /// `org rename <old> <new>` — both positional, old first. Swapping
71 /// them renames the wrong way with no error at all. 71 /// 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.
72 func rename(to newName: String) async -> Bool { 77 func rename(to newName: String) async -> Bool {
73 let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines) 78 let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
74 guard !newName.isEmpty else { return false } 79 guard !newName.isEmpty else { return false }
75 await perform(["org", "rename", orgName, newName]) 80 working = true
76 return actionError == nil 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 }
77 } 95 }
78 96
79 func createTeam(_ team: String) async { 97 func createTeam(_ team: String) async {
gitbayTests/SmallActionTests.swift +27 −3
@@ -81,15 +81,39 @@ struct OrgCreateRenameTests {
81 81
82 /// Both names are positional, OLD first. Swapping them renames the 82 /// Both names are positional, OLD first. Swapping them renames the
83 /// wrong way with no error at all. 83 /// 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 {
85 let (client, stub) = try makeClient() 90 let (client, stub) = try makeClient()
86 let model = OrgViewModel(client: client, orgName: "krz") 91 let model = OrgViewModel(client: client, orgName: "krz")
87 stub.enqueue(.init(status: 200, json: ok)) 92 stub.enqueue(.init(status: 200, json: ok))
88 stub.enqueue(.init(status: 200, json: ok)) 93 stub.enqueue(.init(status: 200, json: """
89 _ = await model.rename(to: "kerouac") 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")
90 100
101 #expect(renamed)
91 let write = try #require(stub.seen.first { $0.method == "POST" }) 102 let write = try #require(stub.seen.first { $0.method == "POST" })
92 #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"]) 103 #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 }
93 } 117 }
94 118
95 @Test func aBlankRenameSendsNothing() async throws { 119 @Test func aBlankRenameSendsNothing() async throws {