Transfer and delete a repository !166

merged merged by cmc on 2026-10-14 07:30 UTC · krz/gitbay-ios:repo-transfer-delete into main

5 files changed, +236 −2

Layout: unified · split

gitbay/Repos/RepoSettingsViewModel.swift +51
@@ -177,6 +177,7 @@ final class RepoSettingsViewModel {
177177 defer { working = false }
178178 do {
179179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self)
180 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
180181 return renamed?.repo
181182 } catch let error as GitbayError {
182183 actionError = error.userFacingMessage
@@ -186,6 +187,56 @@ final class RepoSettingsViewModel {
186187 return nil
187188 }
188189
190 nonisolated private struct Transferred: Decodable, Sendable {
191 let repo: String
192 }
193
194 /// `repo transfer <owner/name> <new-owner>`: the repository moves to
195 /// another account or organization and its clone URLs change. Returns
196 /// the new path; this screen's is gone.
197 func transfer(to owner: String) async -> String? {
198 let owner = owner.trimmingCharacters(in: .whitespaces)
199 guard !owner.isEmpty else { return nil }
200 working = true
201 actionError = nil
202 defer { working = false }
203 do {
204 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self)
205 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
206 return moved?.repo
207 } catch let error as GitbayError {
208 // The owner is typed: a refusal of it says what to fix.
209 actionError = error.messageForTypedInput
210 } catch {
211 actionError = GitbayError.transport(error).userFacingMessage
212 }
213 return nil
214 }
215
216 /// `repo delete <owner/name> --yes`. The screen asks for the path to
217 /// be typed first, as the web does; --yes is that confirmation.
218 func delete() async -> Bool {
219 working = true
220 actionError = nil
221 defer { working = false }
222 do {
223 try await client.run(["repo", "delete", repoPath, "--yes"])
224 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
225 return true
226 } catch GitbayError.notFound {
227 // The client retries a failure once. A delete that removed the
228 // row and then failed cleaning up answers the retry with not
229 // found: the repository is gone either way.
230 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
231 return true
232 } catch let error as GitbayError {
233 actionError = error.userFacingMessage
234 } catch {
235 actionError = GitbayError.transport(error).userFacingMessage
236 }
237 return false
238 }
239
189240 func loadBranches() async {
190241 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
191242 branches = refs?.branches.map(\.name) ?? []
gitbay/Views/Dashboard/DashboardView.swift +4
@@ -95,6 +95,10 @@ struct DashboardView: View {
9595 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
9696 Task { await model.load() }
9797 }
98 // A pinned repository deleted or moved elsewhere.
99 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { _ in
100 Task { await model.load() }
101 }
98102 .onChange(of: model.state.value?.unread) { _, count in
99103 if let count { unread = count }
100104 }
gitbay/Views/Repos/RepoSettingsView.swift +104 −2
@@ -17,6 +17,16 @@ struct RepoSettingsView: View {
1717 /// Set once a rename succeeds: this screen's repository is gone and
1818 /// the renamed one is a value push away.
1919 @State private var renamedTo: String?
20 @State private var transferring = false
21 @State private var newOwner = ""
22 @State private var transferConfirm = ""
23 @State private var deleting = false
24 @State private var deleteConfirm = ""
25 /// Set once the repository is deleted: nothing on this screen applies.
26 @State private var deleted = false
27 /// A refused rename, transfer or delete, shown where the user is: the
28 /// screen's banner sits at the top of a long list, out of sight.
29 @State private var refusal: String?
2030 @State private var loadedOnce = false
2131 @State private var removingTopic: String?
2232 @State private var newGrantUser = ""
@@ -37,12 +47,21 @@ struct RepoSettingsView: View {
3747 }
3848 .accessibilityIdentifier("settings-renamed-link")
3949 } header: {
40 Text("Renamed")
50 Text("Moved")
4151 } footer: {
4252 Text("This screen's repository moved; nothing here applies to it any more.")
4353 }
4454 }
45 if let loaded = model.state.value {
55 if deleted {
56 Section {
57 Label("\(model.repoPath) is deleted", systemImage: "trash")
58 .font(.gbSans(.subheadline))
59 .accessibilityIdentifier("settings-deleted")
60 } footer: {
61 Text("Its issues, merge requests, releases and builds went with it.")
62 }
63 }
64 if !deleted, renamedTo == nil, let loaded = model.state.value {
4665 if let error = model.actionError {
4766 Section {
4867 GBNotice(error, .gbWarn)
@@ -60,12 +79,17 @@ struct RepoSettingsView: View {
6079 webhooksSection
6180 depsSection()
6281 renameSection
82 transferSection
83 deleteSection
6384 }
6485 }
6586 .overlay { LoadStateOverlay(state: model.state) }
6687 .navigationTitle("Settings")
6788 .navigationBarTitleDisplayMode(.inline)
6889 .task {
90 // A screen returned to after its repository moved or went has
91 // nothing to load; the notice is all it shows.
92 guard !deleted, renamedTo == nil else { return }
6993 await model.load()
7094 if !loadedOnce, let loaded = model.state.value {
7195 descriptionText = loaded.detail.description ?? ""
@@ -77,6 +101,8 @@ struct RepoSettingsView: View {
77101 .task { await model.loadGrants() }
78102 .task { await model.loadBranches() }
79103 .refreshable {
104 // Nothing to reload once the repository is gone or moved.
105 guard !deleted, renamedTo == nil else { return }
80106 await model.load()
81107 await model.loadDeps()
82108 await model.loadGrants()
@@ -108,6 +134,8 @@ struct RepoSettingsView: View {
108134 Task {
109135 if let path = await model.rename(to: newName) {
110136 renamedTo = path
137 } else {
138 refusal = model.actionError
111139 }
112140 }
113141 }
@@ -117,6 +145,47 @@ struct RepoSettingsView: View {
117145 } message: {
118146 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
119147 }
148 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
149 Button("OK", role: .cancel) {}
150 } message: {
151 Text(refusal ?? "")
152 }
153 .alert("Transfer \(model.repoPath)", isPresented: $transferring) {
154 TextField("New owner", text: $newOwner)
155 .autocorrectionDisabled()
156 .textInputAutocapitalization(.never)
157 TextField("type \(model.repoPath) to confirm", text: $transferConfirm)
158 .autocorrectionDisabled()
159 .textInputAutocapitalization(.never)
160 Button("Transfer") {
161 Task {
162 if let path = await model.transfer(to: newOwner) {
163 renamedTo = path
164 } else {
165 refusal = model.actionError
166 }
167 }
168 }
169 .disabled(newOwner.trimmingCharacters(in: .whitespaces).isEmpty
170 || transferConfirm != model.repoPath)
171 Button("Cancel", role: .cancel) {}
172 } message: {
173 Text("The repository moves to another account or organization, and its clone URLs change. Type \(model.repoPath) to confirm.")
174 }
175 .alert("Delete \(model.repoPath)", isPresented: $deleting) {
176 TextField("type \(model.repoPath) to confirm", text: $deleteConfirm)
177 .autocorrectionDisabled()
178 .textInputAutocapitalization(.never)
179 Button("Delete", role: .destructive) {
180 Task {
181 if await model.delete() { deleted = true } else { refusal = model.actionError }
182 }
183 }
184 .disabled(deleteConfirm != model.repoPath)
185 Button("Cancel", role: .cancel) {}
186 } message: {
187 Text("This deletes the repository with its issues, merge requests, releases and builds, and cannot be undone. Type \(model.repoPath) to confirm.")
188 }
120189 .confirmationDialog(
121190 "Remove topic \(removingTopic ?? "")?",
122191 isPresented: Binding(
@@ -504,6 +573,39 @@ struct RepoSettingsView: View {
504573 }
505574 }
506575
576 private var transferSection: some View {
577 Section {
578 Button {
579 newOwner = ""
580 transferConfirm = ""
581 transferring = true
582 } label: {
583 Label("Transfer repository", systemImage: "arrow.left.arrow.right")
584 .font(.gbSans(.subheadline))
585 }
586 .disabled(model.working)
587 .accessibilityIdentifier("settings-transfer-button")
588 } footer: {
589 Text("To another account or an organization you can create repositories in.")
590 }
591 }
592
593 private var deleteSection: some View {
594 Section {
595 Button(role: .destructive) {
596 deleteConfirm = ""
597 deleting = true
598 } label: {
599 Label("Delete repository", systemImage: "trash")
600 .font(.gbSans(.subheadline))
601 }
602 .disabled(model.working)
603 .accessibilityIdentifier("settings-delete-button")
604 } footer: {
605 Text("Deletes it with everything in it. Only its owner or an admin can.")
606 }
607 }
608
507609 private func depsSection() -> some View {
508610 Section {
509611 if let deps = model.deps {
gitbay/Views/Repos/RepoView.swift +5
@@ -98,6 +98,11 @@ struct RepoView: View {
9898 .toolbar { toolbar }
9999 .task { await model.load() }
100100 .refreshable { await model.load() }
101 // Settings, pushed on top, may delete or move this repository;
102 // reloading turns this screen into the not-found it now is.
103 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { note in
104 if note.object as? String == path { Task { await model.load() } }
105 }
101106 .alert("Fork \(path)", isPresented: $confirmingFork) {
102107 TextField("New name (optional)", text: $forkName)
103108 .autocorrectionDisabled()
gitbayTests/RepoTransferDeleteTests.swift added +72
@@ -0,0 +1,72 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func posted(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22@MainActor
23struct RepoTransferDeleteTests {
24
25 @Test func transferNamesTheNewOwnerAndReturnsTheNewPath() async throws {
26 let (client, stub) = try makeClient()
27 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"repo":"krz/app","was":"cmc/app"},"exit_code":0}"#, match: "cmd"))
28 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
29
30 let moved = await model.transfer(to: " krz ")
31
32 #expect(moved == "krz/app")
33 #expect(try posted(stub) == [["repo", "transfer", "cmc/app", "krz"]])
34 }
35
36 @Test func aRefusedTransferSaysWhy() async throws {
37 let (client, stub) = try makeClient()
38 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"you cannot create repositories in krz","exit_code":4}"#, match: "cmd"))
39 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
40
41 #expect(await model.transfer(to: "krz") == nil)
42 #expect(model.actionError == "you cannot create repositories in krz")
43 }
44
45 @Test func deleteSendsYesAndReportsIt() async throws {
46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"cmc/app"},"exit_code":0}"#, match: "cmd"))
48 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
49
50 #expect(await model.delete())
51 #expect(try posted(stub) == [["repo", "delete", "cmc/app", "--yes"]])
52 }
53
54 @Test func aRefusedDeleteSaysWhy() async throws {
55 let (client, stub) = try makeClient()
56 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"only the owner or an admin can delete cmc/app","exit_code":4}"#, match: "cmd"))
57 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
58
59 #expect(!(await model.delete()))
60 #expect(model.actionError == "only the owner or an admin can delete cmc/app")
61 }
62
63 @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"removing the directory: busy","exit_code":1}"#, match: "cmd"))
66 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository cmc/app","exit_code":3}"#, match: "cmd"))
67 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
68
69 #expect(await model.delete())
70 #expect(model.actionError == nil)
71 }
72}