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 {
186186 return nil
187187 }
188188
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
189231 func loadBranches() async {
190232 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
191233 branches = refs?.branches.map(\.name) ?? []
gitbay/Views/Repos/RepoSettingsView.swift +87 −2
@@ -17,6 +17,13 @@ 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
2027 @State private var loadedOnce = false
2128 @State private var removingTopic: String?
2229 @State private var newGrantUser = ""
@@ -37,12 +44,21 @@ struct RepoSettingsView: View {
3744 }
3845 .accessibilityIdentifier("settings-renamed-link")
3946 } header: {
40 Text("Renamed")
47 Text("Moved")
4148 } footer: {
4249 Text("This screen's repository moved; nothing here applies to it any more.")
4350 }
4451 }
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 {
4662 if let error = model.actionError {
4763 Section {
4864 GBNotice(error, .gbWarn)
@@ -60,6 +76,8 @@ struct RepoSettingsView: View {
6076 webhooksSection
6177 depsSection()
6278 renameSection
79 transferSection
80 deleteSection
6381 }
6482 }
6583 .overlay { LoadStateOverlay(state: model.state) }
@@ -117,6 +135,40 @@ struct RepoSettingsView: View {
117135 } message: {
118136 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
119137 }
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 }
120172 .confirmationDialog(
121173 "Remove topic \(removingTopic ?? "")?",
122174 isPresented: Binding(
@@ -504,6 +556,39 @@ struct RepoSettingsView: View {
504556 }
505557 }
506558
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
507592 private func depsSection() -> some View {
508593 Section {
509594 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}