Commit 6d46dd6ba2

6d46dd6ba2217f2af6b5989c00470ef45e0d7bdd

parent: 2acd56957d

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:31 UTC

Import a repository from a remote (!74)

The Repositories plus is now a menu: New Repository, or Import from a Remote. Import dispatches repo import --from; a token for a private source goes over stdin with --token-stdin, never in the URL. The live suite picks New Repository from the menu; no live import, since the result could not be cleaned up.

Layout: unified · split

gitbay/Repos/RepoCreateViewModel.swift +20 −4
@@ -1,7 +1,8 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `repo create <owner/name> [--private]`. 4/// `repo create <owner/name> [--private]` and
5/// `repo import <owner/name> --from <url> [--private] [--token-stdin]`.
5@Observable 6@Observable
6@MainActor 7@MainActor
7final class RepoCreateViewModel { 8final class RepoCreateViewModel {
@@ -21,13 +22,28 @@ final class RepoCreateViewModel {
21 22
22 /// Returns the created repo's path, or nil with `errorMessage` set. 23 /// Returns the created repo's path, or nil with `errorMessage` set.
23 func create(path: String, isPrivate: Bool) async -> String? { 24 func create(path: String, isPrivate: Bool) async -> String? {
25 var argv = ["repo", "create", path.trimmingCharacters(in: .whitespaces)]
26 if isPrivate { argv.append("--private") }
27 return await run(argv, stdin: nil)
28 }
29
30 /// The token, when there is one, travels on stdin and never in the
31 /// URL: the server refuses credentials embedded in `--from`.
32 func importRepo(path: String, from: String, isPrivate: Bool, token: String) async -> String? {
33 var argv = ["repo", "import", path.trimmingCharacters(in: .whitespaces),
34 "--from", from.trimmingCharacters(in: .whitespaces)]
35 if isPrivate { argv.append("--private") }
36 let token = token.trimmingCharacters(in: .whitespacesAndNewlines)
37 if !token.isEmpty { argv.append("--token-stdin") }
38 return await run(argv, stdin: token.isEmpty ? nil : token + "\n")
39 }
40
41 private func run(_ argv: [String], stdin: String?) async -> String? {
24 working = true 42 working = true
25 errorMessage = nil 43 errorMessage = nil
26 defer { working = false } 44 defer { working = false }
27 do { 45 do {
28 var argv = ["repo", "create", path.trimmingCharacters(in: .whitespaces)] 46 let created = try await client.run(argv, stdin: stdin, as: Created.self)
29 if isPrivate { argv.append("--private") }
30 let created = try await client.run(argv, as: Created.self)
31 return created?.path 47 return created?.path
32 } catch let error as GitbayError { 48 } catch let error as GitbayError {
33 errorMessage = error.userFacingMessage 49 errorMessage = error.userFacingMessage
gitbay/Views/Repos/RepoListView.swift +97 −2
@@ -6,6 +6,7 @@ struct RepoListView: View {
6 @State private var model: RepoListViewModel 6 @State private var model: RepoListViewModel
7 @State private var createModel: RepoCreateViewModel 7 @State private var createModel: RepoCreateViewModel
8 @State private var composing = false 8 @State private var composing = false
9 @State private var importing = false
9 10
10 init(client: GitbayClient) { 11 init(client: GitbayClient) {
11 _model = State(initialValue: RepoListViewModel(client: client)) 12 _model = State(initialValue: RepoListViewModel(client: client))
@@ -34,8 +35,9 @@ struct RepoListView: View {
34 .accessibilityIdentifier("repo-bookmarks-button") 35 .accessibilityIdentifier("repo-bookmarks-button")
35 } 36 }
36 ToolbarItem(placement: .topBarTrailing) { 37 ToolbarItem(placement: .topBarTrailing) {
37 Button { 38 Menu {
38 composing = true 39 Button("New Repository", systemImage: "plus") { composing = true }
40 Button("Import from a Remote", systemImage: "square.and.arrow.down") { importing = true }
39 } label: { 41 } label: {
40 Image(systemName: "plus") 42 Image(systemName: "plus")
41 } 43 }
@@ -51,6 +53,15 @@ struct RepoListView: View {
51 Task { await model.load() } 53 Task { await model.load() }
52 } 54 }
53 } 55 }
56 .sheet(isPresented: $importing) {
57 RepoImportSheet(
58 model: createModel,
59 ownerPrefix: session.current?.username ?? ""
60 ) {
61 importing = false
62 Task { await model.load() }
63 }
64 }
54 .task { await model.load() } 65 .task { await model.load() }
55 .refreshable { await model.load() } 66 .refreshable { await model.load() }
56 } 67 }
@@ -124,6 +135,90 @@ struct RepoCreateSheet: View {
124 } 135 }
125} 136}
126 137
138/// `repo import <owner/name> --from <url> [--private] [--token-stdin]`.
139/// A token for a private source goes over stdin; the command refuses one
140/// embedded in the URL.
141struct RepoImportSheet: View {
142
143 let model: RepoCreateViewModel
144 let ownerPrefix: String
145 let onImported: () -> Void
146
147 @Environment(\.dismiss) private var dismiss
148 @State private var path = ""
149 @State private var from = ""
150 @State private var isPrivate = false
151 @State private var token = ""
152 @State private var seeded = false
153
154 private var pathLooksRight: Bool {
155 path.contains("/") && !path.hasSuffix("/") && !path.hasPrefix("/")
156 }
157
158 var body: some View {
159 NavigationStack {
160 Form {
161 Section {
162 TextField("owner/name", text: $path)
163 .autocorrectionDisabled()
164 .textInputAutocapitalization(.never)
165 .accessibilityIdentifier("repo-import-path")
166 TextField("https://…/repo.git", text: $from)
167 .autocorrectionDisabled()
168 .textInputAutocapitalization(.never)
169 .keyboardType(.URL)
170 .accessibilityIdentifier("repo-import-from")
171 } footer: {
172 Text("The source is cloned once; it is not kept as a mirror.")
173 }
174 Section {
175 Toggle("Private", isOn: $isPrivate)
176 SecureField("Token (private source)", text: $token)
177 .autocorrectionDisabled()
178 .textInputAutocapitalization(.never)
179 } footer: {
180 Text("A token is sent once to clone and is not stored.")
181 }
182 if let error = model.errorMessage {
183 Section {
184 GBNotice(error)
185 }
186 }
187 }
188 .navigationTitle("Import Repository")
189 .navigationBarTitleDisplayMode(.inline)
190 .toolbar {
191 ToolbarItem(placement: .cancellationAction) {
192 Button("Cancel") { dismiss() }
193 }
194 ToolbarItem(placement: .confirmationAction) {
195 if model.working {
196 ProgressView()
197 } else {
198 Button("Import") {
199 Task {
200 if await model.importRepo(
201 path: path, from: from, isPrivate: isPrivate, token: token) != nil {
202 onImported()
203 }
204 }
205 }
206 .disabled(!pathLooksRight || from.trimmingCharacters(in: .whitespaces).isEmpty)
207 .accessibilityIdentifier("repo-import-submit")
208 }
209 }
210 }
211 .interactiveDismissDisabled(model.working)
212 .onAppear {
213 if !seeded {
214 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
215 seeded = true
216 }
217 }
218 }
219 }
220}
221
127struct RepoRow: View { 222struct RepoRow: View {
128 let repo: RepoSummary 223 let repo: RepoSummary
129 224
gitbayTests/RepoManagementTests.swift +31
@@ -262,6 +262,37 @@ struct RepoCreateViewModelTests {
262 #expect(try argvOf(try #require(stub.seen.first)) == ["repo", "create", "cmc/notes"]) 262 #expect(try argvOf(try #require(stub.seen.first)) == ["repo", "create", "cmc/notes"])
263 } 263 }
264 264
265 @Test func importSendsTheSourceAndTheTokenOverStdin() async throws {
266 let (client, stub) = try makeClient()
267 stub.enqueue(.init(status: 200, json:
268 #"{"protocol_version":1,"data":{"path":"cmc/mirror","visibility":"private"},"exit_code":0}"#))
269 let model = RepoCreateViewModel(client: client)
270
271 let path = await model.importRepo(
272 path: " cmc/mirror ", from: "https://example.com/x/y.git", isPrivate: true, token: "s3cret")
273
274 #expect(path == "cmc/mirror")
275 let write = try #require(stub.seen.first)
276 #expect(try argvOf(write) == ["repo", "import", "cmc/mirror",
277 "--from", "https://example.com/x/y.git", "--private", "--token-stdin"])
278 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
279 #expect(body["stdin"] as? String == "s3cret\n")
280 }
281
282 @Test func importWithoutATokenSendsNoStdin() async throws {
283 let (client, stub) = try makeClient()
284 stub.enqueue(.init(status: 200, json:
285 #"{"protocol_version":1,"data":{"path":"cmc/mirror","visibility":"public"},"exit_code":0}"#))
286 let model = RepoCreateViewModel(client: client)
287
288 _ = await model.importRepo(path: "cmc/mirror", from: "https://example.com/x/y.git", isPrivate: false, token: "")
289
290 let write = try #require(stub.seen.first)
291 #expect(try argvOf(write) == ["repo", "import", "cmc/mirror", "--from", "https://example.com/x/y.git"])
292 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
293 #expect(body["stdin"] == nil)
294 }
295
265 @Test func aRefusalSurfaces() async throws { 296 @Test func aRefusalSurfaces() async throws {
266 let (client, stub) = try makeClient() 297 let (client, stub) = try makeClient()
267 stub.enqueue(.init(status: 403, json: 298 stub.enqueue(.init(status: 403, json:
gitbayUITests/LiveSmokeUITests.swift +3
@@ -406,6 +406,9 @@ extension LiveSmokeUITests {
406 selectTab("Repositories") 406 selectTab("Repositories")
407 app.descendants(matching: .any).matching(identifier: "repo-create-button") 407 app.descendants(matching: .any).matching(identifier: "repo-create-button")
408 .firstMatch.tap() 408 .firstMatch.tap()
409 let newRepo = app.buttons["New Repository"].firstMatch
410 XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open")
411 newRepo.tap()
409 let pathField = app.descendants(matching: .any) 412 let pathField = app.descendants(matching: .any)
410 .matching(identifier: "repo-create-path").firstMatch 413 .matching(identifier: "repo-create-path").firstMatch
411 XCTAssertTrue(pathField.waitForExistence(timeout: 5)) 414 XCTAssertTrue(pathField.waitForExistence(timeout: 5))