Commit f390bcc59e

f390bcc59e3088c7765b688a871f2debfec27368

parent: fe2832564a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 07:07 UTC

Transfer and delete a repository

Settings end with Transfer repository and Delete repository, each behind
the repository's path typed out, as the web asks. A transfer leaves the
screen pointing at the new path, as a rename does; a deletion leaves
only a note that it is gone.

Closes #31

Layout: unified · split

gitbay/Repos/RepoSettingsViewModel.swift +42
@@ -186,6 +186,48 @@ final class RepoSettingsViewModel {
186 return nil 186 return nil
187 } 187 }
188 188
189 nonisolated private struct Transferred: Decodable, Sendable {
190 let repo: String
191 }
192
193 /// `repo transfer <owner/name> <new-owner>`: the repository moves to
194 /// another account or organization and its clone URLs change. Returns
195 /// the new path; this screen's is gone.
196 func transfer(to owner: String) async -> String? {
197 let owner = owner.trimmingCharacters(in: .whitespaces)
198 guard !owner.isEmpty else { return nil }
199 working = true
200 actionError = nil
201 defer { working = false }
202 do {
203 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self)
204 return moved?.repo
205 } catch let error as GitbayError {
206 // The owner is typed: a refusal of it says what to fix.
207 actionError = error.messageForTypedInput
208 } catch {
209 actionError = GitbayError.transport(error).userFacingMessage
210 }
211 return nil
212 }
213
214 /// `repo delete <owner/name> --yes`. The screen asks for the path to
215 /// be typed first, as the web does; --yes is that confirmation.
216 func delete() async -> Bool {
217 working = true
218 actionError = nil
219 defer { working = false }
220 do {
221 try await client.run(["repo", "delete", repoPath, "--yes"])
222 return true
223 } catch let error as GitbayError {
224 actionError = error.userFacingMessage
225 } catch {
226 actionError = GitbayError.transport(error).userFacingMessage
227 }
228 return false
229 }
230
189 func loadBranches() async { 231 func loadBranches() async {
190 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) 232 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
191 branches = refs?.branches.map(\.name) ?? [] 233 branches = refs?.branches.map(\.name) ?? []
gitbay/Views/Repos/RepoSettingsView.swift +87 −2
@@ -17,6 +17,13 @@ struct RepoSettingsView: View {
17 /// Set once a rename succeeds: this screen's repository is gone and 17 /// Set once a rename succeeds: this screen's repository is gone and
18 /// the renamed one is a value push away. 18 /// the renamed one is a value push away.
19 @State private var renamedTo: String? 19 @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
20 @State private var loadedOnce = false 27 @State private var loadedOnce = false
21 @State private var removingTopic: String? 28 @State private var removingTopic: String?
22 @State private var newGrantUser = "" 29 @State private var newGrantUser = ""
@@ -37,12 +44,21 @@ struct RepoSettingsView: View {
37 } 44 }
38 .accessibilityIdentifier("settings-renamed-link") 45 .accessibilityIdentifier("settings-renamed-link")
39 } header: { 46 } header: {
40 Text("Renamed") 47 Text("Moved")
41 } footer: { 48 } footer: {
42 Text("This screen's repository moved; nothing here applies to it any more.") 49 Text("This screen's repository moved; nothing here applies to it any more.")
43 } 50 }
44 } 51 }
45 if let loaded = model.state.value { 52 if deleted {
53 Section {
54 Label("\(model.repoPath) is deleted", systemImage: "trash")
55 .font(.gbSans(.subheadline))
56 .accessibilityIdentifier("settings-deleted")
57 } footer: {
58 Text("Its issues, merge requests, releases and builds went with it.")
59 }
60 }
61 if !deleted, renamedTo == nil, let loaded = model.state.value {
46 if let error = model.actionError { 62 if let error = model.actionError {
47 Section { 63 Section {
48 GBNotice(error, .gbWarn) 64 GBNotice(error, .gbWarn)
@@ -60,6 +76,8 @@ struct RepoSettingsView: View {
60 webhooksSection 76 webhooksSection
61 depsSection() 77 depsSection()
62 renameSection 78 renameSection
79 transferSection
80 deleteSection
63 } 81 }
64 } 82 }
65 .overlay { LoadStateOverlay(state: model.state) } 83 .overlay { LoadStateOverlay(state: model.state) }
@@ -117,6 +135,40 @@ struct RepoSettingsView: View {
117 } message: { 135 } message: {
118 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.") 136 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
119 } 137 }
138 .alert("Transfer \(model.repoPath)", isPresented: $transferring) {
139 TextField("New owner", text: $newOwner)
140 .autocorrectionDisabled()
141 .textInputAutocapitalization(.never)
142 TextField("type \(model.repoPath) to confirm", text: $transferConfirm)
143 .autocorrectionDisabled()
144 .textInputAutocapitalization(.never)
145 Button("Transfer") {
146 Task {
147 if let path = await model.transfer(to: newOwner) {
148 renamedTo = path
149 }
150 }
151 }
152 .disabled(newOwner.trimmingCharacters(in: .whitespaces).isEmpty
153 || transferConfirm != model.repoPath)
154 Button("Cancel", role: .cancel) {}
155 } message: {
156 Text("The repository moves to another account or organization, and its clone URLs change. Type \(model.repoPath) to confirm.")
157 }
158 .alert("Delete \(model.repoPath)", isPresented: $deleting) {
159 TextField("type \(model.repoPath) to confirm", text: $deleteConfirm)
160 .autocorrectionDisabled()
161 .textInputAutocapitalization(.never)
162 Button("Delete", role: .destructive) {
163 Task {
164 if await model.delete() { deleted = true }
165 }
166 }
167 .disabled(deleteConfirm != model.repoPath)
168 Button("Cancel", role: .cancel) {}
169 } message: {
170 Text("This deletes the repository with its issues, merge requests, releases and builds, and cannot be undone. Type \(model.repoPath) to confirm.")
171 }
120 .confirmationDialog( 172 .confirmationDialog(
121 "Remove topic \(removingTopic ?? "")?", 173 "Remove topic \(removingTopic ?? "")?",
122 isPresented: Binding( 174 isPresented: Binding(
@@ -504,6 +556,39 @@ struct RepoSettingsView: View {
504 } 556 }
505 } 557 }
506 558
559 private var transferSection: some View {
560 Section {
561 Button {
562 newOwner = ""
563 transferConfirm = ""
564 transferring = true
565 } label: {
566 Label("Transfer repository", systemImage: "arrow.left.arrow.right")
567 .font(.gbSans(.subheadline))
568 }
569 .disabled(model.working)
570 .accessibilityIdentifier("settings-transfer-button")
571 } footer: {
572 Text("To another account or an organization you can create repositories in.")
573 }
574 }
575
576 private var deleteSection: some View {
577 Section {
578 Button(role: .destructive) {
579 deleteConfirm = ""
580 deleting = true
581 } label: {
582 Label("Delete repository", systemImage: "trash")
583 .font(.gbSans(.subheadline))
584 }
585 .disabled(model.working)
586 .accessibilityIdentifier("settings-delete-button")
587 } footer: {
588 Text("Deletes it with everything in it. Only its owner or an admin can.")
589 }
590 }
591
507 private func depsSection() -> some View { 592 private func depsSection() -> some View {
508 Section { 593 Section {
509 if let deps = model.deps { 594 if let deps = model.deps {
gitbayTests/RepoTransferDeleteTests.swift added +62
@@ -0,0 +1,62 @@
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}