diff --git a/apps/mac/Sources/SurgeCodeMac/Model/AppModel.swift b/apps/mac/Sources/SurgeCodeMac/Model/AppModel.swift index 4e1915fe1..4f9a08650 100644 --- a/apps/mac/Sources/SurgeCodeMac/Model/AppModel.swift +++ b/apps/mac/Sources/SurgeCodeMac/Model/AppModel.swift @@ -2171,6 +2171,73 @@ public final class AppModel { } } + /// Runs repository setup first, then registers the resulting folder. + /// Returning the concrete project lets New Session proceed without + /// guessing when a shell snapshot will arrive. + public func onboardProject(_ request: ProjectOnboardingRequest) async -> Project? { + let requestedPath = request.projectPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !requestedPath.isEmpty else { return nil } + + do { + let registered: Project + switch request { + case .importFolder: + registered = try await registerOnboardedProject( + path: requestedPath, createWorkspaceRootIfMissing: false) + + case .cloneRepository( + let provider, let repository, let remoteURL, _, let cloneProtocol): + let result = try await backend.cloneRepository( + provider: provider, + repository: repository, + remoteURL: remoteURL, + destinationPath: requestedPath, + protocol: cloneProtocol) + registered = try await registerOnboardedProject( + path: result.cwd, createWorkspaceRootIfMissing: false) + + case .initializeRepository: + registered = try await registerOnboardedProject( + path: requestedPath, createWorkspaceRootIfMissing: true) + try await backend.initializeRepository(path: registered.path) + + case .publishFolder( + _, let provider, let repository, let visibility, let cloneProtocol): + // `git init` is idempotent for an existing repository and makes + // publishing an ordinary local folder work as advertised. + try await backend.initializeRepository(path: requestedPath) + _ = try await backend.publishRepository( + path: requestedPath, + provider: provider, + repository: repository, + visibility: visibility, + protocol: cloneProtocol) + registered = try await registerOnboardedProject( + path: requestedPath, createWorkspaceRootIfMissing: false) + } + + await refreshAll() + return projects.first(where: { + GeneralWorkspace.pathsMatch($0.path, registered.path) + }) ?? registered + } catch { + report(error) + return nil + } + } + + private func registerOnboardedProject( + path: String, createWorkspaceRootIfMissing: Bool + ) async throws -> Project { + if let existing = projects.first(where: { + GeneralWorkspace.pathsMatch($0.path, path) + }) { + return existing + } + return try await backend.addProject( + path: path, createWorkspaceRootIfMissing: createWorkspaceRootIfMissing) + } + /// Prefer the provider of the most recently updated active thread when it /// is still runnable; otherwise the first runnable provider. public var preferredQuickChatProvider: ProviderKind? { diff --git a/apps/mac/Sources/SurgeCodeMac/Model/BackendService.swift b/apps/mac/Sources/SurgeCodeMac/Model/BackendService.swift index 6f98a82a8..5851b0622 100644 --- a/apps/mac/Sources/SurgeCodeMac/Model/BackendService.swift +++ b/apps/mac/Sources/SurgeCodeMac/Model/BackendService.swift @@ -153,6 +153,18 @@ public protocol BackendService: Sendable { func renameProject(id: String, name: String) async throws /// Deletes a project and all of its sessions (force-cascades server-side). func deleteProject(id: String) async throws + /// Clone a provider repository name or direct Git URL into a host-local destination. + func cloneRepository( + provider: SourceControlProviderKind?, repository: String?, remoteURL: String?, + destinationPath: String, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlCloneRepositoryResult + /// Initialize a Git repository at an existing host-local folder. + func initializeRepository(path: String) async throws + /// Create a provider repository, attach it as a remote, and push when commits exist. + func publishRepository( + path: String, provider: SourceControlProviderKind, repository: String, + visibility: SourceControlRepositoryVisibility, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlPublishRepositoryResult /// Start (or keep) a live VCS status subscription for a thread's /// workspace; status arrives via `.vcsStatusChanged` events. @@ -231,6 +243,27 @@ public extension BackendService { /// Default for conformers without one-shot VCS refresh (test fakes). func refreshVcsStatus(threadID: String) async throws {} + func cloneRepository( + provider: SourceControlProviderKind?, repository: String?, remoteURL: String?, + destinationPath: String, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlCloneRepositoryResult { + throw BackendServiceError.unsupportedOperation( + "Repository cloning is unavailable on this connection.") + } + + func initializeRepository(path: String) async throws { + throw BackendServiceError.unsupportedOperation( + "Repository initialization is unavailable on this connection.") + } + + func publishRepository( + path: String, provider: SourceControlProviderKind, repository: String, + visibility: SourceControlRepositoryVisibility, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlPublishRepositoryResult { + throw BackendServiceError.unsupportedOperation( + "Repository publishing is unavailable on this connection.") + } + } /// One page of archived threads as returned by `archivedThreadsPage`. diff --git a/apps/mac/Sources/SurgeCodeMac/Model/LiveBackend.swift b/apps/mac/Sources/SurgeCodeMac/Model/LiveBackend.swift index 23cdb11c0..97c5d56d8 100644 --- a/apps/mac/Sources/SurgeCodeMac/Model/LiveBackend.swift +++ b/apps/mac/Sources/SurgeCodeMac/Model/LiveBackend.swift @@ -2922,6 +2922,37 @@ public actor LiveBackend: BackendService { } } + public func cloneRepository( + provider: SourceControlProviderKind?, repository: String?, remoteURL: String?, + destinationPath: String, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlCloneRepositoryResult { + guard let client = currentClient else { throw LiveBackendError.notConnected } + return try await client.cloneRepository( + provider: provider, + repository: repository, + remoteURL: remoteURL, + destinationPath: destinationPath, + protocol: cloneProtocol) + } + + public func initializeRepository(path: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + try await client.initializeRepository(cwd: path) + } + + public func publishRepository( + path: String, provider: SourceControlProviderKind, repository: String, + visibility: SourceControlRepositoryVisibility, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlPublishRepositoryResult { + guard let client = currentClient else { throw LiveBackendError.notConnected } + return try await client.publishRepository( + cwd: path, + provider: provider, + repository: repository, + visibility: visibility, + protocol: cloneProtocol) + } + // MARK: - BackendService: git / VCS /// Live status subscriptions keyed by threadID; re-established on demand diff --git a/apps/mac/Sources/SurgeCodeMac/Model/MockBackend.swift b/apps/mac/Sources/SurgeCodeMac/Model/MockBackend.swift index 3301dbace..36f4f1a7b 100644 --- a/apps/mac/Sources/SurgeCodeMac/Model/MockBackend.swift +++ b/apps/mac/Sources/SurgeCodeMac/Model/MockBackend.swift @@ -420,6 +420,47 @@ public final class MockBackend: BackendService, @unchecked Sendable { public func deleteProject(id: String) async throws { await state.deleteProject(id: id) } + + public func cloneRepository( + provider: SourceControlProviderKind?, repository: String?, remoteURL: String?, + destinationPath: String, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlCloneRepositoryResult { + let resolvedURL = + remoteURL + ?? "https://example.com/\(repository ?? "repository").git" + return SourceControlCloneRepositoryResult( + cwd: destinationPath, + remoteUrl: resolvedURL, + repository: repository.map { + SourceControlRepositoryInfo( + provider: provider?.rawValue ?? "unknown", + nameWithOwner: $0, + url: resolvedURL, + sshUrl: "git@example.com:\($0).git") + }) + } + + public func initializeRepository(path: String) async throws { + _ = path + } + + public func publishRepository( + path: String, provider: SourceControlProviderKind, repository: String, + visibility: SourceControlRepositoryVisibility, protocol cloneProtocol: SourceControlCloneProtocol? + ) async throws -> SourceControlPublishRepositoryResult { + _ = (path, visibility, cloneProtocol) + let url = "https://example.com/\(repository)" + return SourceControlPublishRepositoryResult( + repository: SourceControlRepositoryInfo( + provider: provider.rawValue, + nameWithOwner: repository, + url: url, + sshUrl: "git@example.com:\(repository).git"), + remoteName: "origin", + remoteUrl: url, + branch: "main", + status: "remote_added") + } } // MARK: - Actor-isolated mutable state + demo data diff --git a/apps/mac/Sources/SurgeCodeMac/Model/ProjectOnboarding.swift b/apps/mac/Sources/SurgeCodeMac/Model/ProjectOnboarding.swift new file mode 100644 index 000000000..1c57efebb --- /dev/null +++ b/apps/mac/Sources/SurgeCodeMac/Model/ProjectOnboarding.swift @@ -0,0 +1,32 @@ +import Foundation +import T3Kit + +public enum ProjectOnboardingRequest: Sendable, Equatable { + case importFolder(path: String) + case cloneRepository( + provider: SourceControlProviderKind?, + repository: String?, + remoteURL: String?, + destinationPath: String, + protocol: SourceControlCloneProtocol + ) + case initializeRepository(path: String) + case publishFolder( + path: String, + provider: SourceControlProviderKind, + repository: String, + visibility: SourceControlRepositoryVisibility, + protocol: SourceControlCloneProtocol + ) + + public var projectPath: String { + switch self { + case .importFolder(let path), .initializeRepository(let path): + path + case .cloneRepository(_, _, _, let destinationPath, _): + destinationPath + case .publishFolder(let path, _, _, _, _): + path + } + } +} diff --git a/apps/mac/Sources/SurgeCodeMac/UI/Shell/NewSessionSheet.swift b/apps/mac/Sources/SurgeCodeMac/UI/Shell/NewSessionSheet.swift index 18574ad99..793626056 100644 --- a/apps/mac/Sources/SurgeCodeMac/UI/Shell/NewSessionSheet.swift +++ b/apps/mac/Sources/SurgeCodeMac/UI/Shell/NewSessionSheet.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import T3Kit /// Keeps creation tied to the device the user explicitly selected. A missing /// connection means a remote disappeared; it must never be interpreted as the @@ -10,8 +11,9 @@ enum NewSessionTargetPolicy { } } -/// Glass sheet for starting a new session: pick a project (existing or a new -/// folder), choose a provider, then create the thread. +/// Glass sheet for starting a new session: pick an existing project or import, +/// clone, initialize, or publish one; choose an agent provider; then create +/// the thread. /// /// Layout: a full-bleed frosted scenery header previews the scene the thread /// will be named after; below it, labeled sections walk the choices top to @@ -29,6 +31,11 @@ struct NewSessionSheet: View { @UIState private var projectSearch = "" @UIState private var provider: ProviderKind = .claude @UIState private var newProjectPath: String = "" + @UIState private var projectOnboardingKind: ProjectOnboardingKind = .importFolder + @UIState private var repositorySource: RepositorySource = .github + @UIState private var repositoryInput = "" + @UIState private var cloneProtocol: SourceControlCloneProtocol = .ssh + @UIState private var repositoryVisibility: SourceControlRepositoryVisibility = .private @UIState private var isBusy = false @UIState private var errorMessage: String? /// Preview of the scene the created thread will be named after. Sampled @@ -37,10 +44,49 @@ struct NewSessionSheet: View { private enum Mode: String, CaseIterable, Identifiable { case existing = "Existing" - case new = "New Folder" + case new = "Add Project" var id: String { rawValue } } + private enum ProjectOnboardingKind: String, CaseIterable, Identifiable { + case importFolder = "Import" + case clone = "Clone" + case initialize = "Create" + case publish = "Publish" + + var id: String { rawValue } + } + + private enum RepositorySource: String, CaseIterable, Identifiable { + case url = "Git URL" + case github = "GitHub" + case gitlab = "GitLab" + case bitbucket = "Bitbucket" + case azureDevOps = "Azure DevOps" + + var id: String { rawValue } + + var provider: SourceControlProviderKind? { + switch self { + case .url: nil + case .github: .github + case .gitlab: .gitlab + case .bitbucket: .bitbucket + case .azureDevOps: .azureDevOps + } + } + + var repositoryPlaceholder: String { + switch self { + case .url: "https://host.example/owner/repository.git" + case .github: "owner/repository" + case .gitlab: "group/project" + case .bitbucket: "workspace/repository" + case .azureDevOps: "project/repository" + } + } + } + var body: some View { VStack(spacing: 0) { header @@ -287,8 +333,99 @@ struct NewSessionSheet: View { } private var newProjectContent: some View { + VStack(alignment: .leading, spacing: 10) { + Picker("Add project using", selection: $projectOnboardingKind) { + ForEach(ProjectOnboardingKind.allCases) { kind in + Text(kind.rawValue).tag(kind) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .onChange(of: projectOnboardingKind) { + if projectOnboardingKind == .publish, repositorySource == .url { + repositorySource = .github + } + clearError() + } + + switch projectOnboardingKind { + case .importFolder: + projectPathField( + icon: "folder.badge.plus", + placeholder: "Existing project folder", + browseMessage: "Choose a local project folder") + onboardingHint("Import an existing folder without changing its Git setup.") + + case .clone: + repositorySourceFields(allowsURL: true) + projectPathField( + icon: "arrow.down.to.line.compact", + placeholder: "Clone destination", + browseMessage: "Choose or create an empty clone destination") + onboardingHint("The destination must be new or empty.") + + case .initialize: + projectPathField( + icon: "folder.badge.gearshape", + placeholder: "New repository folder", + browseMessage: "Choose or create the repository folder") + onboardingHint("Create the folder if needed and initialize it as a Git repository.") + + case .publish: + projectPathField( + icon: "folder.badge.plus", + placeholder: "Local folder to publish", + browseMessage: "Choose the local folder to publish") + repositorySourceFields(allowsURL: false) + Picker("Visibility", selection: $repositoryVisibility) { + ForEach(SourceControlRepositoryVisibility.allCases) { visibility in + Text(visibility.displayName).tag(visibility) + } + } + .pickerStyle(.segmented) + onboardingHint( + "Initialize Git if needed, create the remote repository, and push existing commits.") + } + } + .padding(12) + .background(cardFill, in: cardShape) + .overlay(cardShape.stroke(cardStroke, lineWidth: 1)) + } + + @ViewBuilder + private func repositorySourceFields(allowsURL: Bool) -> some View { + HStack(spacing: 8) { + Picker("Source", selection: $repositorySource) { + ForEach( + RepositorySource.allCases.filter { allowsURL || $0 != .url } + ) { source in + Text(source.rawValue).tag(source) + } + } + .labelsHidden() + .frame(width: 126) + .onChange(of: repositorySource) { clearError() } + + TextField(repositorySource.repositoryPlaceholder, text: $repositoryInput) + .textFieldStyle(.roundedBorder) + .onChange(of: repositoryInput) { clearError() } + } + + if repositorySource != .url { + Picker("Clone protocol", selection: $cloneProtocol) { + ForEach(SourceControlCloneProtocol.allCases) { cloneProtocol in + Text(cloneProtocol.displayName).tag(cloneProtocol) + } + } + .pickerStyle(.segmented) + } + } + + private func projectPathField( + icon: String, placeholder: String, browseMessage: String + ) -> some View { HStack(spacing: 10) { - Image(systemName: "folder.badge.plus") + Image(systemName: icon) .font(.callout) .foregroundStyle(.secondary) .frame(width: 28, height: 28) @@ -296,23 +433,22 @@ struct NewSessionSheet: View { Color.primary.opacity(0.06), in: RoundedRectangle( cornerRadius: AlpineTheme.Corners.control, style: .continuous)) - TextField("Project folder path", text: $newProjectPath) + TextField(placeholder, text: $newProjectPath) .textFieldStyle(.plain) - .onChange(of: newProjectPath) { - clearError() - } - Button { - pickFolder() - } label: { - Text("Browse…") + .onChange(of: newProjectPath) { clearError() } + Button("Browse…") { + pickFolder(message: browseMessage) } .buttonStyle(.glass) .controlSize(.small) } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(cardFill, in: cardShape) - .overlay(cardShape.stroke(cardStroke, lineWidth: 1)) + } + + private func onboardingHint(_ text: String) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } // MARK: - Provider @@ -442,14 +578,14 @@ struct NewSessionSheet: View { // MARK: - Actions /// Native directory chooser; fills the path field with the selection. - private func pickFolder() { + private func pickFolder(message: String = "Choose the project folder") { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.prompt = "Choose" - panel.message = "Choose the project folder" + panel.message = message let typed = newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines) let base = model.settings?.addProjectBaseDirectory ?? "" if !typed.isEmpty { @@ -476,7 +612,45 @@ struct NewSessionSheet: View { } switch mode { case .existing: return selectedProjectID != nil - case .new: return !newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .new: return projectOnboardingRequest != nil + } + } + + private var projectOnboardingRequest: ProjectOnboardingRequest? { + let path = newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + let repository = repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + + switch projectOnboardingKind { + case .importFolder: + return .importFolder(path: path) + case .initialize: + return .initializeRepository(path: path) + case .clone: + guard !repository.isEmpty else { return nil } + if repositorySource == .url { + return .cloneRepository( + provider: nil, + repository: nil, + remoteURL: repository, + destinationPath: path, + protocol: cloneProtocol) + } + guard let provider = repositorySource.provider else { return nil } + return .cloneRepository( + provider: provider, + repository: repository, + remoteURL: nil, + destinationPath: path, + protocol: cloneProtocol) + case .publish: + guard !repository.isEmpty, let provider = repositorySource.provider else { return nil } + return .publishFolder( + path: path, + provider: provider, + repository: repository, + visibility: repositoryVisibility, + protocol: cloneProtocol) } } @@ -580,14 +754,8 @@ struct NewSessionSheet: View { provider: provider, scenery: scenery) case .new: - let path = newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !path.isEmpty else { return } - await model.addProject(path: path) - // The server normalizes added paths (~ expansion, standardization), - // so compare normalized forms — a typed path rarely matches verbatim. - if let project = model.projects.first(where: { - GeneralWorkspace.pathsMatch($0.path, path) - }) { + guard let request = projectOnboardingRequest else { return } + if let project = await model.onboardProject(request) { createdThread = await model.createSceneThread( projectID: project.id, provider: provider, diff --git a/apps/mac/Sources/T3Kit/SourceControlRpc.swift b/apps/mac/Sources/T3Kit/SourceControlRpc.swift new file mode 100644 index 000000000..a47e181bb --- /dev/null +++ b/apps/mac/Sources/T3Kit/SourceControlRpc.swift @@ -0,0 +1,156 @@ +// sourceControl.* repository onboarding RPCs plus vcs.init. + +import Foundation + +public enum SourceControlProviderKind: String, Codable, CaseIterable, Sendable, Identifiable { + case github + case gitlab + case azureDevOps = "azure-devops" + case bitbucket + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .github: "GitHub" + case .gitlab: "GitLab" + case .azureDevOps: "Azure DevOps" + case .bitbucket: "Bitbucket" + } + } +} + +public enum SourceControlCloneProtocol: String, Codable, CaseIterable, Sendable, Identifiable { + case ssh + case https + + public var id: String { rawValue } + public var displayName: String { rawValue.uppercased() } +} + +public enum SourceControlRepositoryVisibility: String, Codable, CaseIterable, Sendable, Identifiable { + case `private` + case `public` + + public var id: String { rawValue } + public var displayName: String { rawValue.capitalized } +} + +public struct SourceControlRepositoryInfo: Decodable, Sendable, Equatable { + public var provider: String + public var nameWithOwner: String + public var url: String + public var sshUrl: String + + public init(provider: String, nameWithOwner: String, url: String, sshUrl: String) { + self.provider = provider + self.nameWithOwner = nameWithOwner + self.url = url + self.sshUrl = sshUrl + } +} + +public struct SourceControlCloneRepositoryResult: Decodable, Sendable, Equatable { + public var cwd: String + public var remoteUrl: String + public var repository: SourceControlRepositoryInfo? + + public init( + cwd: String, remoteUrl: String, repository: SourceControlRepositoryInfo? = nil + ) { + self.cwd = cwd + self.remoteUrl = remoteUrl + self.repository = repository + } +} + +public struct SourceControlPublishRepositoryResult: Decodable, Sendable, Equatable { + public var repository: SourceControlRepositoryInfo + public var remoteName: String + public var remoteUrl: String + public var branch: String + public var upstreamBranch: String? + /// `"pushed"` when commits were pushed; `"remote_added"` for an empty repository. + public var status: String + + public init( + repository: SourceControlRepositoryInfo, + remoteName: String, + remoteUrl: String, + branch: String, + upstreamBranch: String? = nil, + status: String + ) { + self.repository = repository + self.remoteName = remoteName + self.remoteUrl = remoteUrl + self.branch = branch + self.upstreamBranch = upstreamBranch + self.status = status + } +} + +private struct SourceControlCloneRepositoryInput: Encodable, Sendable { + var provider: SourceControlProviderKind? + var repository: String? + var remoteUrl: String? + var destinationPath: String + var `protocol`: SourceControlCloneProtocol? +} + +private struct SourceControlPublishRepositoryInput: Encodable, Sendable { + var cwd: String + var provider: SourceControlProviderKind + var repository: String + var visibility: SourceControlRepositoryVisibility + var remoteName: String? + var `protocol`: SourceControlCloneProtocol? +} + +private struct VcsInitRepositoryInput: Encodable, Sendable { + var cwd: String + var kind = "git" +} + +extension T3Client { + public func cloneRepository( + provider: SourceControlProviderKind? = nil, + repository: String? = nil, + remoteURL: String? = nil, + destinationPath: String, + protocol cloneProtocol: SourceControlCloneProtocol? = nil + ) async throws -> SourceControlCloneRepositoryResult { + try await call( + "sourceControl.cloneRepository", + SourceControlCloneRepositoryInput( + provider: provider, + repository: repository, + remoteUrl: remoteURL, + destinationPath: destinationPath, + protocol: cloneProtocol)) + } + + public func initializeRepository(cwd: String) async throws { + let _: JSONValue = try await call( + "vcs.init", VcsInitRepositoryInput(cwd: cwd)) + } + + public func publishRepository( + cwd: String, + provider: SourceControlProviderKind, + repository: String, + visibility: SourceControlRepositoryVisibility, + remoteName: String? = nil, + protocol cloneProtocol: SourceControlCloneProtocol? = nil + ) async throws -> SourceControlPublishRepositoryResult { + try await call( + "sourceControl.publishRepository", + SourceControlPublishRepositoryInput( + cwd: cwd, + provider: provider, + repository: repository, + visibility: visibility, + remoteName: remoteName, + protocol: cloneProtocol)) + } +} diff --git a/apps/mac/Tests/SurgeCodeMacTests/ProjectOnboardingTests.swift b/apps/mac/Tests/SurgeCodeMacTests/ProjectOnboardingTests.swift new file mode 100644 index 000000000..00a8274cf --- /dev/null +++ b/apps/mac/Tests/SurgeCodeMacTests/ProjectOnboardingTests.swift @@ -0,0 +1,61 @@ +import Testing +import T3Kit + +@testable import SurgeCodeMac + +@MainActor +struct ProjectOnboardingTests { + @Test + func requestExposesResultingProjectPath() { + let requests: [ProjectOnboardingRequest] = [ + .importFolder(path: "/tmp/imported"), + .cloneRepository( + provider: .github, + repository: "owner/repo", + remoteURL: nil, + destinationPath: "/tmp/cloned", + protocol: .ssh), + .initializeRepository(path: "/tmp/created"), + .publishFolder( + path: "/tmp/published", + provider: .gitlab, + repository: "owner/repo", + visibility: .private, + protocol: .https), + ] + + #expect(requests.map(\.projectPath) == [ + "/tmp/imported", "/tmp/cloned", "/tmp/created", "/tmp/published", + ]) + } + + @Test + func onboardingRegistersEveryRepositorySource() async { + let model = AppModel(backend: MockBackend()) + let requests: [ProjectOnboardingRequest] = [ + .importFolder(path: "/tmp/surgecode-onboarding-import"), + .cloneRepository( + provider: .github, + repository: "owner/repo", + remoteURL: nil, + destinationPath: "/tmp/surgecode-onboarding-clone", + protocol: .ssh), + .initializeRepository(path: "/tmp/surgecode-onboarding-create"), + .publishFolder( + path: "/tmp/surgecode-onboarding-publish", + provider: .gitlab, + repository: "owner/repo", + visibility: .public, + protocol: .https), + ] + + for request in requests { + let project = await model.onboardProject(request) + #expect(project?.path == request.projectPath) + } + + for request in requests { + #expect(model.projects.contains { $0.path == request.projectPath }) + } + } +}