From 6117369b6fdc5ca2fb48231f90993905983e2306 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 3 Oct 2026 11:53:39 +0700 Subject: [PATCH 1/2] feat: Add self-hosted Git provider support Introduce GitHub Enterprise Server and GitLab Self-Managed configuration, personal access token authentication, and discovery of self-hosted accounts gated behind Pro access. --- README.md | 2 + docs/self-hosted-git-providers.md | 34 ++++ macgit/App/GitProviderAccountController.swift | 73 +++++++- macgit/App/PullRequestController.swift | 7 +- .../App/RepositoryVisibilityController.swift | 5 +- macgit/App/macgitApp.swift | 2 + macgit/Models/GitProviderAccountModels.swift | 52 +++++- .../GitProviderDiscoveredRepository.swift | 9 + macgit/Models/GitProviderRepositoryPage.swift | 7 + macgit/Services/BranchProtectionService.swift | 2 +- .../Services/GitHubProviderAuthService.swift | 16 +- .../Services/GitHubPullRequestService.swift | 33 ++-- .../GitHubRepositoryVisibilityService.swift | 6 +- .../Services/GitLabProviderAuthService.swift | 3 +- .../Services/GitLabPullRequestService.swift | 4 +- .../GitLabRepositoryVisibilityService.swift | 4 +- .../GitProviderAccountAccessPolicy.swift | 3 + .../GitProviderAccountPreferenceStore.swift | 2 +- .../GitProviderCredentialResolver.swift | 11 +- ...itProviderRepositoryDiscoveryService.swift | 64 +++++++ macgit/Services/GitProviderTokenVault.swift | 2 +- .../Services/GitRemoteIdentityResolver.swift | 28 ++- macgit/Services/GitStatusService+Clone.swift | 12 +- .../GitStatusService+RemoteCredential.swift | 8 +- .../LocalGitProviderAccountStore.swift | 2 +- ...itProviderAccountsPresentationPolicy.swift | 17 +- .../Account/GitProviderAddAccountSheet.swift | 167 +++++++++++++----- .../Common/GitProviderRepositoryBrowser.swift | 71 ++++++++ macgit/Views/MainWindow/RepoPickerView.swift | 24 ++- .../GitProviderAccountControllerTests.swift | 40 +++++ .../GitProviderAccountsSectionTests.swift | 2 +- macgitTests/GitProviderTokenVaultTests.swift | 2 +- macgitTests/SelfHostedGitProviderTests.swift | 113 ++++++++++++ 33 files changed, 705 insertions(+), 122 deletions(-) create mode 100644 docs/self-hosted-git-providers.md create mode 100644 macgit/Models/GitProviderDiscoveredRepository.swift create mode 100644 macgit/Models/GitProviderRepositoryPage.swift create mode 100644 macgit/Services/GitProviderRepositoryDiscoveryService.swift create mode 100644 macgit/Views/Common/GitProviderRepositoryBrowser.swift create mode 100644 macgitTests/SelfHostedGitProviderTests.swift diff --git a/README.md b/README.md index b1483c8f..45aa7b96 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,5 @@ For security issues, please refer to [SECURITY.md](SECURITY.md). ## License This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE). + +For GitHub Enterprise Server and GitLab Self-Managed setup, see [Self-hosted Git providers](docs/self-hosted-git-providers.md). diff --git a/docs/self-hosted-git-providers.md b/docs/self-hosted-git-providers.md new file mode 100644 index 00000000..8c4a46ff --- /dev/null +++ b/docs/self-hosted-git-providers.md @@ -0,0 +1,34 @@ +# Self-hosted Git providers + +Commit+ supports configured GitHub Enterprise Server and GitLab Self-Managed installations alongside GitHub.com and GitLab.com. + +Connecting or reconnecting self-managed servers requires an active Pro plan. Existing accounts and their stored credentials are retained when a subscription changes. + +## Connect an account + +1. Open Git provider connections and choose **Add Account**. +2. Select **GitHub** or **GitLab**, then enable **Self-hosted server**. +3. Enter the installation's HTTPS server URL, such as `https://github.company.com` or `https://gitlab.company.com`. GitLab installations may include a port or installation subpath, such as `https://source.company.com:8443/gitlab`. +4. Select **HTTPS**, enter a **Personal Access Token**, and choose **Connect Account**. Commit+ validates the token against the server's user API before storing it in the local Keychain. +5. Choose **Save**. To rotate the token, edit that account and connect with its replacement token. + +GitHub tokens need access to the target repositories and permissions for the API actions you use. For a classic token, `repo` covers private repositories; pushing workflow-file changes may require `workflow`. Fine-grained token availability and permissions depend on the Enterprise Server version and administrator policy. GitLab personal access tokens with `api` scope support the integrated API and Git operations. Server policy, token permissions, and repository permissions still govern each operation. + +OAuth remains the existing connection method for GitHub.com and GitLab.com. Self-hosted connections use personal access tokens; instance-specific OAuth application setup is not included. + +## Work with repositories + +In **Clone**, enter the repository's clone URL or expand **Browse connected repositories**, choose an account, and select a repository. The browser loads additional pages with **Load more**. Discovery includes repositories returned by the provider for that token; GitLab discovery uses membership filtering. Fetch, pull, push, remote-branch loading, PR/MR APIs, repository visibility, and branch protection use the configured server and matching credentials. + +You can also select **SSH**, choose a private key, and test it against the configured hostname. SSH connectivity uses your SSH configuration, including host aliases and custom SSH ports. An HTTPS API port is not an SSH port. SSH-only accounts do not provide API repository discovery or PR/MR access unless an API token is also retained on that account. When several server installations share one hostname, SSH URLs cannot identify their HTTPS port/subpath unambiguously; use HTTPS or your existing Git/SSH configuration for those remotes. Credentials already configured in Git remain available for repositories without a matching Commit+ account. + +## Connection errors + +- For unreachable servers or timeouts, check the URL, DNS, network, and company VPN. +- For certificate errors, install and trust your company's certificate using macOS Keychain. Commit+ does not disable HTTPS certificate validation. +- For invalid or expired tokens, edit the account and connect with a replacement token. +- For permission errors, check token scopes, repository access, and your administrator's token/SSO policies. A successful account connection validates identity, not every repository permission. + +## Validation status + +The implementation has automated regression coverage for server parsing, remote identification, credential isolation, authentication endpoint routing, repository discovery, and API routing. A successful macOS build proves compilation only. Final deployment-specific verification requires private repositories on actual GitHub Enterprise Server and GitLab Self-Managed installations, including clone/fetch/pull/push and PR/MR operations. No minimum server version has been certified against a live installation yet. diff --git a/macgit/App/GitProviderAccountController.swift b/macgit/App/GitProviderAccountController.swift index 829d609c..63b4f180 100644 --- a/macgit/App/GitProviderAccountController.swift +++ b/macgit/App/GitProviderAccountController.swift @@ -35,6 +35,7 @@ final class GitProviderAccountController: ObservableObject { private let gitLabAuthService: (any GitLabProviderOAuthAuthenticating)? private let gitLabRedirectURI: URL private let openURL: (URL) -> Bool + private let hasProAccess: () -> Bool private let multipleAccountAccess: () -> FeatureAccessDecision private let accountAccessPolicy = GitProviderAccountAccessPolicy() private var cacheOwnerUID: String? @@ -53,6 +54,7 @@ final class GitProviderAccountController: ObservableObject { gitLabAuthService: (any GitLabProviderOAuthAuthenticating)? = nil, gitLabRedirectURI: URL = GitLabProviderAuthConfiguration.appConfiguration().redirectURI, openURL: @escaping (URL) -> Bool = { _ in false }, + hasProAccess: @escaping () -> Bool = { false }, multipleAccountAccess: @escaping () -> FeatureAccessDecision = { .denied(.requiresPro) } @@ -67,8 +69,13 @@ final class GitProviderAccountController: ObservableObject { self.gitLabRedirectURI = gitLabRedirectURI self.openURL = openURL self.multipleAccountAccess = multipleAccountAccess + self.hasProAccess = hasProAccess } + var canConnectSelfHosted: Bool { hasProAccess() } + + func refreshConnectionAccess() { objectWillChange.send() } + var accountCreationDecision: GitProviderAccountCreationDecision { accountAccessPolicy.creationDecision( existingAccountCount: accounts.count, @@ -113,10 +120,45 @@ final class GitProviderAccountController: ObservableObject { } func connectSelfHostedGitLab(hostURL: URL) async { + guard authorizeConnection(to: GitProviderHost(kind: .gitlab, baseURL: hostURL)) else { return } guard authorizeNewAccountCreation() else { return } await startGitLabDeviceAuthorization(host: GitProviderHost(kind: .gitlab, baseURL: hostURL).normalized) } + func connectPersonalAccessToken(host: GitProviderHost, accessToken: String, replacing existingAccount: GitProviderAccount? = nil) async { + errorMessage = nil + let accessToken = accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard let validatedHost = GitProviderHost.configured(kind: host.kind, value: host.baseURL.absoluteString), + validatedHost.kind != .bitbucket, !accessToken.isEmpty else { + errorMessage = "Enter a valid HTTPS server URL and personal access token." + return + } + guard authorizeConnection(to: validatedHost) else { return } + isLoading = true + defer { isLoading = false } + do { + let token = GitProviderToken(accessToken: accessToken, tokenType: "Bearer") + var account: GitProviderAccount + switch validatedHost.kind { + case .github: + account = try await GitHubProviderAuthService(configuration: .appConfiguration()).fetchAccount(token: token, macgitUID: accountOwnerID, host: validatedHost) + case .gitlab: + account = try await GitLabProviderAuthService(configuration: .appConfiguration()).fetchAccount(token: token, macgitUID: accountOwnerID, host: validatedHost) + case .bitbucket: return + } + try Task.checkCancellation() + if let existingAccount, !hasSameProviderIdentity(existingAccount, account) { + throw GitProviderAuthError.providerMessage("This token belongs to a different account. Add it as a new account instead.") + } + // PAT scope validation happens on each Git/API operation; OAuth scopes are not applicable. + account.permissions["authentication"] = "personalAccessToken" + account.scopes = [] + try await saveAuthorizedAccount(account, token: token) + } catch { + errorMessage = GitProviderAuthError.connectionMessage(error) + } + } + func connectBitbucket( username: String, apiToken: String, @@ -176,6 +218,14 @@ final class GitProviderAccountController: ObservableObject { } func reconnect(_ account: GitProviderAccount) async { + guard authorizeConnection(to: GitProviderHost(kind: account.provider, baseURL: account.hostURL)) else { return } + if account.provider != .bitbucket { + let publicHost = account.provider == .github ? GitProviderHost.githubDotCom : GitProviderHost.gitlabDotCom + if GitProviderHost(kind: account.provider, baseURL: account.hostURL).normalized != publicHost { + errorMessage = "Edit this self-hosted account and enter a new personal access token or test its SSH key." + return + } + } switch account.provider { case .github: await startGitHubDeviceAuthorization() @@ -301,7 +351,7 @@ final class GitProviderAccountController: ObservableObject { return token } - guard account.scopes.contains("write_repository") else { + guard account.permissions["authentication"] == "personalAccessToken" || account.scopes.contains("write_repository") else { await markReauthorizationRequired(account) throw GitProviderAuthError.reauthorizationRequired } @@ -384,6 +434,7 @@ final class GitProviderAccountController: ObservableObject { username usernameOverride: String? = nil, replacing existingAccount: GitProviderAccount? = nil ) async { + guard authorizeConnection(to: host) else { return } if existingAccount == nil, !authorizeNewAccountCreation() { return } @@ -620,17 +671,19 @@ final class GitProviderAccountController: ObservableObject { hostURL: URL, username: String ) -> String { - let hostIdentifier = (hostURL.host(percentEncoded: false) ?? hostURL.absoluteString).lowercased() + let hostIdentifier = GitProviderHost.accountHostIdentifier(hostURL) return "\(macgitUID):\(provider.rawValue):\(hostIdentifier):\(username)" } private func saveAuthorizedAccount(_ account: GitProviderAccount, token: GitProviderToken) async throws { try validateAccountCreation(for: account) + let previousToken = try tokenVault.readToken(for: account) try tokenVault.saveToken(token, for: account) do { try await store.save(account) } catch { - try? tokenVault.deleteToken(for: account) + if let previousToken { try? tokenVault.saveToken(previousToken, for: account) } + else { try? tokenVault.deleteToken(for: account) } throw error } @@ -649,8 +702,7 @@ final class GitProviderAccountController: ObservableObject { _ rhs: GitProviderAccount ) -> Bool { lhs.provider == rhs.provider - && lhs.hostURL.host(percentEncoded: false)?.lowercased() - == rhs.hostURL.host(percentEncoded: false)?.lowercased() + && GitProviderHost.identityKey(lhs.hostURL) == GitProviderHost.identityKey(rhs.hostURL) && lhs.providerUserID == rhs.providerUserID } @@ -665,7 +717,18 @@ final class GitProviderAccountController: ObservableObject { } } + private func authorizeConnection(to host: GitProviderHost) -> Bool { + guard !host.isSelfHosted || canConnectSelfHosted else { + errorMessage = GitProviderAccountAccessError.selfHostedRequiresPro.localizedDescription + return false + } + return true + } + private func validateAccountCreation(for candidate: GitProviderAccount? = nil) throws { + if let candidate, GitProviderHost(kind: candidate.provider, baseURL: candidate.hostURL).isSelfHosted, !canConnectSelfHosted { + throw GitProviderAccountAccessError.selfHostedRequiresPro + } if let candidate, accounts.contains(where: { hasSameProviderIdentity($0, candidate) }) { return diff --git a/macgit/App/PullRequestController.swift b/macgit/App/PullRequestController.swift index 95498ffc..68306649 100644 --- a/macgit/App/PullRequestController.swift +++ b/macgit/App/PullRequestController.swift @@ -312,7 +312,8 @@ final class PullRequestController: ObservableObject { guard let remoteIdentity = GitRemoteIdentityResolver.identity( from: remoteURLString, - knownGitLabHosts: connectedGitLabHosts + knownGitLabHosts: connectedGitLabHosts, + knownHosts: providerAccountController.accounts.map { GitProviderHost(kind: $0.provider, baseURL: $0.hostURL) } ) else { items = [] resetPagination() @@ -994,7 +995,7 @@ final class PullRequestController: ObservableObject { } private func supportsProviderAPI(_ account: GitProviderAccount) -> Bool { - account.transportProtocol == .https || !account.scopes.isEmpty + account.transportProtocol == .https || account.permissions["authentication"] == "personalAccessToken" || !account.scopes.isEmpty } private func resetPagination() { @@ -1011,7 +1012,7 @@ final class PullRequestController: ObservableObject { } private func normalizedHost(_ url: URL) -> String { - (url.host(percentEncoded: false) ?? url.absoluteString).lowercased() + GitProviderHost.identityKey(url) } private func suggestedTitle(for branch: String) -> String { diff --git a/macgit/App/RepositoryVisibilityController.swift b/macgit/App/RepositoryVisibilityController.swift index b8008e3b..4229fbd6 100644 --- a/macgit/App/RepositoryVisibilityController.swift +++ b/macgit/App/RepositoryVisibilityController.swift @@ -81,7 +81,8 @@ final class RepositoryVisibilityController: ObservableObject { guard let remoteURL = await remoteURLProvider(repositoryURL, remote), let identity = GitRemoteIdentityResolver.identity( from: remoteURL, - knownGitLabHosts: knownGitLabHosts + knownGitLabHosts: knownGitLabHosts, + knownHosts: accounts.map { GitProviderHost(kind: $0.provider, baseURL: $0.hostURL) } ) else { foundUnknown = true continue @@ -203,6 +204,6 @@ final class RepositoryVisibilityController: ObservableObject { } private func normalizedHost(_ url: URL) -> String { - (url.host(percentEncoded: false) ?? url.absoluteString).lowercased() + GitProviderHost.identityKey(url) } } diff --git a/macgit/App/macgitApp.swift b/macgit/App/macgitApp.swift index 8ba9d569..fc550613 100644 --- a/macgit/App/macgitApp.swift +++ b/macgit/App/macgitApp.swift @@ -90,6 +90,7 @@ struct macgitApp: App { gitLabAuthService: GitLabProviderAuthService(configuration: gitLabProviderConfiguration), gitLabRedirectURI: gitLabProviderConfiguration.redirectURI, openURL: NSWorkspace.shared.open, + hasProAccess: { accountController.entitlement.hasProAccess }, multipleAccountAccess: { featureAccessController.decision( for: .multipleProviderAccounts, @@ -253,6 +254,7 @@ struct macgitApp: App { aiProviderController.invalidateAvailability() } .onChange(of: accountController.entitlement) { _, _ in + providerAccountController.refreshConnectionAccess() aiProviderController.invalidateAvailability() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in diff --git a/macgit/Models/GitProviderAccountModels.swift b/macgit/Models/GitProviderAccountModels.swift index 8018dbda..e3a5b4c4 100644 --- a/macgit/Models/GitProviderAccountModels.swift +++ b/macgit/Models/GitProviderAccountModels.swift @@ -53,12 +53,62 @@ struct GitProviderHost: Hashable, Codable { baseURL: URL(string: "https://bitbucket.org")! ) + var isSelfHosted: Bool { + switch kind { + case .github: return normalized != Self.githubDotCom + case .gitlab: return normalized != Self.gitlabDotCom + case .bitbucket: return false + } + } + + var apiURL: URL { + let server = normalized.baseURL + switch kind { + case .github: + return server == Self.githubDotCom.baseURL ? URL(string: "https://api.github.com")! : server.appending(path: "api/v3") + case .gitlab: return server.appending(path: "api/v4") + case .bitbucket: return URL(string: "https://api.bitbucket.org/2.0")! + } + } + + /// Preserve existing keys for root installations, isolating ports and subpaths. + static func identityKey(_ url: URL) -> String { + let host = url.host(percentEncoded: false)?.lowercased() ?? "" + let port = (url.scheme?.lowercased() == "https" && url.port == 443) ? "" : (url.port.map { ":\($0)" } ?? "") + let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return host + port + (path.isEmpty ? "" : "/" + path) + } + + static func accountHostIdentifier(_ url: URL) -> String { + identityKey(url).replacingOccurrences(of: "%", with: "%25").replacingOccurrences(of: "/", with: "%2F") + } + + static func configured(kind: GitProviderKind, value: String) -> GitProviderHost? { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, !value.contains(where: { $0.isWhitespace }), + let url = URL(string: value.contains("://") ? value : "https://" + value), + url.scheme?.lowercased() == "https", let host = url.host, !host.isEmpty, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil, + url.port == nil || (1...65535).contains(url.port!), + !url.pathComponents.contains(".."), + kind != .github || url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty else { return nil } + return GitProviderHost(kind: kind, baseURL: url).normalized + } + var normalized: GitProviderHost { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) if components?.scheme == nil { components?.scheme = "https" } - components?.path = "" + let scheme = components?.scheme?.lowercased() + let hostname = components?.host?.lowercased() + components?.scheme = scheme + components?.host = hostname + if scheme == "https", components?.port == 443 { components?.port = nil } + components?.user = nil + components?.password = nil + let path = baseURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + components?.path = path.isEmpty ? "" : "/" + path components?.query = nil components?.fragment = nil return GitProviderHost(kind: kind, baseURL: components?.url ?? baseURL) diff --git a/macgit/Models/GitProviderDiscoveredRepository.swift b/macgit/Models/GitProviderDiscoveredRepository.swift new file mode 100644 index 00000000..2cd6e933 --- /dev/null +++ b/macgit/Models/GitProviderDiscoveredRepository.swift @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +struct GitProviderDiscoveredRepository: Identifiable, Equatable { + var name: String + var cloneURL: String + var id: String { cloneURL } +} + diff --git a/macgit/Models/GitProviderRepositoryPage.swift b/macgit/Models/GitProviderRepositoryPage.swift new file mode 100644 index 00000000..0fc0f3e4 --- /dev/null +++ b/macgit/Models/GitProviderRepositoryPage.swift @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +struct GitProviderRepositoryPage { + var repositories: [GitProviderDiscoveredRepository] + var hasNextPage: Bool +} + diff --git a/macgit/Services/BranchProtectionService.swift b/macgit/Services/BranchProtectionService.swift index 9c0d2ed4..7a6b00de 100644 --- a/macgit/Services/BranchProtectionService.swift +++ b/macgit/Services/BranchProtectionService.swift @@ -22,7 +22,7 @@ struct BranchProtectionService { let endpoint: String switch identity.provider { case .github: - endpoint = "https://api.github.com/repos/\(encode(identity.ownerPath))/\(encode(identity.repositoryName))/branches/\(encode(branch))" + endpoint = "\(GitProviderHost(kind: .github, baseURL: identity.hostURL).apiURL.absoluteString)/repos/\(encode(identity.ownerPath))/\(encode(identity.repositoryName))/branches/\(encode(branch))" case .gitlab: endpoint = "\(identity.hostURL.absoluteString)/api/v4/projects/\(encode(identity.ownerPath + "/" + identity.repositoryName))/repository/branches/\(encode(branch))" case .bitbucket: diff --git a/macgit/Services/GitHubProviderAuthService.swift b/macgit/Services/GitHubProviderAuthService.swift index eb5e9b8c..a9056277 100644 --- a/macgit/Services/GitHubProviderAuthService.swift +++ b/macgit/Services/GitHubProviderAuthService.swift @@ -82,6 +82,18 @@ enum GitProviderAuthError: LocalizedError, Equatable { case reauthorizationRequired case providerMessage(String) + static func connectionMessage(_ error: Error) -> String { + guard let error = error as? URLError else { return error.localizedDescription } + switch error.code { + case .cannotFindHost, .dnsLookupFailed, .cannotConnectToHost: + return "Cannot reach the Git server. Check the server URL, network connection, and company VPN." + case .timedOut: return "The Git server did not respond in time. Check your network or VPN and try again." + case .serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, .secureConnectionFailed: + return "Cannot establish a trusted HTTPS connection. Check the server certificate and trust your company's certificate in macOS Keychain." + default: return error.localizedDescription + } + } + var errorDescription: String? { switch self { case .invalidConfiguration: @@ -194,7 +206,7 @@ struct GitHubProviderAuthService: GitProviderAuthenticating { macgitUID: String, host: GitProviderHost ) async throws -> GitProviderAccount { - var request = URLRequest(url: apiBaseURL.appendingPathComponent("user")) + var request = URLRequest(url: (host.normalized.baseURL == GitProviderHost.githubDotCom.baseURL ? apiBaseURL : host.apiURL).appendingPathComponent("user")) request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") @@ -205,7 +217,7 @@ struct GitHubProviderAuthService: GitProviderAuthenticating { let timestamp = now() let normalizedHost = host.normalized.baseURL - let hostIdentifier = normalizedHost.host?.lowercased() ?? normalizedHost.absoluteString.lowercased() + let hostIdentifier = GitProviderHost.accountHostIdentifier(normalizedHost) let scopes = response.value(forHTTPHeaderField: "X-OAuth-Scopes")? .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/macgit/Services/GitHubPullRequestService.swift b/macgit/Services/GitHubPullRequestService.swift index bbf74dd2..e49d3d59 100644 --- a/macgit/Services/GitHubPullRequestService.swift +++ b/macgit/Services/GitHubPullRequestService.swift @@ -45,7 +45,7 @@ struct GitHubPullRequestService: PullRequestProviding { var page = 1 while true { var components = URLComponents( - url: apiBaseURL + url: apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -95,7 +95,7 @@ struct GitHubPullRequestService: PullRequestProviding { let normalizedPerPage = min(max(1, perPage), 100) var components = URLComponents( - url: apiBaseURL + url: apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -146,7 +146,7 @@ struct GitHubPullRequestService: PullRequestProviding { throw PullRequestProviderError.unsupportedProvider } - let detailURL = apiBaseURL + let detailURL = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -186,7 +186,7 @@ struct GitHubPullRequestService: PullRequestProviding { while page <= 30 { var components = URLComponents( - url: apiBaseURL + url: apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -227,7 +227,7 @@ struct GitHubPullRequestService: PullRequestProviding { throw PullRequestProviderError.unsupportedProvider } - let url = apiBaseURL + let url = apiURL(for: draft.repository) .appendingPathComponent("repos") .appendingPathComponent(draft.repository.owner) .appendingPathComponent(draft.repository.name) @@ -276,7 +276,7 @@ struct GitHubPullRequestService: PullRequestProviding { } if !draft.assignees.isEmpty { do { - let assigneesURL = apiBaseURL + let assigneesURL = apiURL(for: draft.repository) .appendingPathComponent("repos") .appendingPathComponent(draft.repository.owner) .appendingPathComponent(draft.repository.name) @@ -317,7 +317,7 @@ struct GitHubPullRequestService: PullRequestProviding { throw PullRequestProviderError.unsupportedProvider } - let url = apiBaseURL + let url = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -341,7 +341,7 @@ struct GitHubPullRequestService: PullRequestProviding { throw PullRequestProviderError.unsupportedProvider } - let url = apiBaseURL + let url = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -364,7 +364,7 @@ struct GitHubPullRequestService: PullRequestProviding { token: GitProviderToken, number: Int ) async throws -> [PullRequestComment] { - let commentsURL = apiBaseURL + let commentsURL = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -395,7 +395,7 @@ struct GitHubPullRequestService: PullRequestProviding { token: GitProviderToken, number: Int ) async throws -> [PullRequestComment] { - let reviewsURL = apiBaseURL + let reviewsURL = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -413,7 +413,7 @@ struct GitHubPullRequestService: PullRequestProviding { token: GitProviderToken, number: Int ) async throws -> [PullRequestComment] { - let commentsURL = apiBaseURL + let commentsURL = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -453,7 +453,7 @@ struct GitHubPullRequestService: PullRequestProviding { guard let sha = summary.source.sha, !sha.isEmpty else { return .unknown } - let url = apiBaseURL + let url = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -478,7 +478,7 @@ struct GitHubPullRequestService: PullRequestProviding { repository: GitRepositoryIdentity, token: GitProviderToken ) async -> PullRequestCheckState { - let url = apiBaseURL + let url = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -504,7 +504,7 @@ struct GitHubPullRequestService: PullRequestProviding { guard summary.state == .open else { return .unknown } - let url = apiBaseURL + let url = apiURL(for: repository) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) @@ -521,6 +521,11 @@ struct GitHubPullRequestService: PullRequestProviding { } } + private func apiURL(for repository: GitRepositoryIdentity) -> URL { + let host = GitProviderHost(kind: .github, baseURL: repository.hostURL) + return host.normalized.baseURL == GitProviderHost.githubDotCom.baseURL ? apiBaseURL : host.apiURL + } + private func makeRequest(url: URL, token: GitProviderToken) -> URLRequest { var request = URLRequest(url: url) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") diff --git a/macgit/Services/GitHubRepositoryVisibilityService.swift b/macgit/Services/GitHubRepositoryVisibilityService.swift index b49bbfc0..ce9832cb 100644 --- a/macgit/Services/GitHubRepositoryVisibilityService.swift +++ b/macgit/Services/GitHubRepositoryVisibilityService.swift @@ -34,12 +34,12 @@ struct GitHubRepositoryVisibilityService: RepositoryVisibilityProviding { for repository: GitRepositoryIdentity, token: GitProviderToken? ) async throws -> RepositoryVisibility { - guard repository.provider == .github, - repository.hostURL.host(percentEncoded: false)?.lowercased() == "github.com" else { + guard repository.provider == .github else { throw RepositoryVisibilityProviderError.unsupportedProvider } - let url = apiBaseURL + let host = GitProviderHost(kind: .github, baseURL: repository.hostURL) + let url = (host.normalized.baseURL == GitProviderHost.githubDotCom.baseURL ? apiBaseURL : host.apiURL) .appendingPathComponent("repos") .appendingPathComponent(repository.owner) .appendingPathComponent(repository.name) diff --git a/macgit/Services/GitLabProviderAuthService.swift b/macgit/Services/GitLabProviderAuthService.swift index c0ae2bc3..d071f78e 100644 --- a/macgit/Services/GitLabProviderAuthService.swift +++ b/macgit/Services/GitLabProviderAuthService.swift @@ -267,8 +267,7 @@ struct GitLabProviderAuthService: GitLabProviderOAuthAuthenticating { try validate(response: response, data: data, unauthorizedMeansReauthorization: true) let profile = try decode(UserResponse.self, from: data) let timestamp = now() - let hostIdentifier = normalizedHost.baseURL.host(percentEncoded: false)?.lowercased() - ?? normalizedHost.baseURL.absoluteString.lowercased() + let hostIdentifier = GitProviderHost.accountHostIdentifier(normalizedHost.baseURL) return GitProviderAccount( id: "\(macgitUID):gitlab:\(hostIdentifier):\(profile.id)", diff --git a/macgit/Services/GitLabPullRequestService.swift b/macgit/Services/GitLabPullRequestService.swift index 5c6c39a7..64ef3096 100644 --- a/macgit/Services/GitLabPullRequestService.swift +++ b/macgit/Services/GitLabPullRequestService.swift @@ -299,7 +299,9 @@ struct GitLabPullRequestService: PullRequestProviding { } let suffix = pathComponents.map { "/\($0)" }.joined() - components.percentEncodedPath = "/api/v4/projects/\(encodedProjectPath)\(suffix)" + components.percentEncodedPath = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty + ? "/api/v4/projects/\(encodedProjectPath)\(suffix)" + : "/" + components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/" )) + "/api/v4/projects/\(encodedProjectPath)\(suffix)" components.queryItems = queryItems.isEmpty ? nil : queryItems guard let url = components.url else { throw PullRequestProviderError.repositoryUnavailable diff --git a/macgit/Services/GitLabRepositoryVisibilityService.swift b/macgit/Services/GitLabRepositoryVisibilityService.swift index 58518c3a..744b6e23 100644 --- a/macgit/Services/GitLabRepositoryVisibilityService.swift +++ b/macgit/Services/GitLabRepositoryVisibilityService.swift @@ -39,7 +39,9 @@ struct GitLabRepositoryVisibilityService: RepositoryVisibilityProviding { var components = URLComponents(url: repository.hostURL, resolvingAgainstBaseURL: false) else { throw RepositoryVisibilityProviderError.invalidResponse } - components.percentEncodedPath = "/api/v4/projects/\(encodedPath)" + components.percentEncodedPath = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty + ? "/api/v4/projects/\(encodedPath)" + : "/" + components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/v4/projects/\(encodedPath)" guard let url = components.url else { throw RepositoryVisibilityProviderError.invalidResponse } diff --git a/macgit/Services/GitProviderAccountAccessPolicy.swift b/macgit/Services/GitProviderAccountAccessPolicy.swift index dc1cddd9..8eaf5590 100644 --- a/macgit/Services/GitProviderAccountAccessPolicy.swift +++ b/macgit/Services/GitProviderAccountAccessPolicy.swift @@ -55,11 +55,14 @@ struct GitProviderAccountAccessPolicy { } enum GitProviderAccountAccessError: LocalizedError, Equatable { + case selfHostedRequiresPro case freeAccountLimitReached(limit: Int) case featureDisabled var errorDescription: String? { switch self { + case .selfHostedRequiresPro: + "Connecting self-managed Git servers requires an active Pro plan." case .freeAccountLimitReached(let limit): "Free plan includes \(limit) Git provider account. Upgrade to Pro to add more." case .featureDisabled: diff --git a/macgit/Services/GitProviderAccountPreferenceStore.swift b/macgit/Services/GitProviderAccountPreferenceStore.swift index f334503c..97e13419 100644 --- a/macgit/Services/GitProviderAccountPreferenceStore.swift +++ b/macgit/Services/GitProviderAccountPreferenceStore.swift @@ -25,7 +25,7 @@ enum GitProviderAccountPreferenceKey { static func make(for identity: GitRemoteIdentity) -> String { [ identity.provider.rawValue, - identity.hostURL.host(percentEncoded: false)?.lowercased() ?? "", + GitProviderHost.identityKey(identity.hostURL), identity.ownerPath, identity.repositoryName, ].joined(separator: "|") diff --git a/macgit/Services/GitProviderCredentialResolver.swift b/macgit/Services/GitProviderCredentialResolver.swift index 21bae257..83cf25bf 100644 --- a/macgit/Services/GitProviderCredentialResolver.swift +++ b/macgit/Services/GitProviderCredentialResolver.swift @@ -71,7 +71,8 @@ struct GitProviderCredentialResolver { guard isHTTPSRemote(remoteURLString) else { return nil } guard let identity = GitRemoteIdentityResolver.identity( from: remoteURLString, - knownGitLabHosts: connectedGitLabHosts + knownGitLabHosts: connectedGitLabHosts, + knownHosts: accounts.map { GitProviderHost(kind: $0.provider, baseURL: $0.hostURL) } ) else { return nil } @@ -107,7 +108,8 @@ struct GitProviderCredentialResolver { guard isSSHRemote(remoteURLString) else { return nil } guard let identity = GitRemoteIdentityResolver.identity( from: remoteURLString, - knownGitLabHosts: connectedGitLabHosts + knownGitLabHosts: connectedGitLabHosts, + knownHosts: accounts.map { GitProviderHost(kind: $0.provider, baseURL: $0.hostURL) } ) else { return nil } @@ -143,7 +145,8 @@ struct GitProviderCredentialResolver { func remoteIdentity(for remoteURLString: String) -> GitRemoteIdentity? { GitRemoteIdentityResolver.identity( from: remoteURLString, - knownGitLabHosts: connectedGitLabHosts + knownGitLabHosts: connectedGitLabHosts, + knownHosts: accounts.map { GitProviderHost(kind: $0.provider, baseURL: $0.hostURL) } ) } @@ -217,6 +220,6 @@ struct GitProviderCredentialResolver { } private func normalizedHost(_ url: URL) -> String { - (url.host(percentEncoded: false) ?? url.absoluteString).lowercased() + GitProviderHost.identityKey(url) } } diff --git a/macgit/Services/GitProviderRepositoryDiscoveryService.swift b/macgit/Services/GitProviderRepositoryDiscoveryService.swift new file mode 100644 index 00000000..4800c717 --- /dev/null +++ b/macgit/Services/GitProviderRepositoryDiscoveryService.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +struct GitProviderRepositoryDiscoveryService { + var httpClient: GitProviderHTTPClient = URLSessionGitProviderHTTPClient() + + @MainActor + func repositories(account: GitProviderAccount, resolver: GitProviderCredentialResolver, page: Int) async throws -> GitProviderRepositoryPage { + let token: GitProviderToken? + if let tokenProvider = resolver.tokenProvider { + token = try await tokenProvider(account) + } else { + token = try resolver.tokenVault.readToken(for: account) + } + guard let token else { throw GitProviderCredentialError.tokenUnavailable(username: account.username) } + return try await repositories(account: account, token: token, page: page) + } + + func repositories(account: GitProviderAccount, token: GitProviderToken, page: Int) async throws -> GitProviderRepositoryPage { + guard account.provider != .bitbucket else { throw GitProviderCredentialError.unsupportedRemote } + let host = GitProviderHost(kind: account.provider, baseURL: account.hostURL) + let endpoint = host.apiURL.appending(path: account.provider == .github ? "user/repos" : "projects") + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "per_page", value: "50"), URLQueryItem(name: "page", value: String(max(1, page)))] + if account.provider == .gitlab { components.queryItems?.append(URLQueryItem(name: "membership", value: "true")) } + var request = URLRequest(url: components.url!) + request.timeoutInterval = 20 + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await httpClient.data(for: request) + guard (200..<300).contains(response.statusCode) else { + throw GitProviderAuthError.providerMessage(response.statusCode == 401 + ? "The personal access token is invalid or expired. Reconnect the account." + : "Repository discovery failed (HTTP \(response.statusCode)). Check token permissions and repository access.") + } + let repositories: [GitProviderDiscoveredRepository] + do { + if account.provider == .github { + repositories = try JSONDecoder().decode([GitHubRepository].self, from: data).map { + GitProviderDiscoveredRepository(name: $0.full_name, cloneURL: account.transportProtocol == .ssh ? $0.ssh_url : $0.clone_url) + } + } else { + repositories = try JSONDecoder().decode([GitLabRepository].self, from: data).map { + GitProviderDiscoveredRepository(name: $0.path_with_namespace, cloneURL: account.transportProtocol == .ssh ? $0.ssh_url_to_repo : $0.http_url_to_repo) + } + } + } catch { throw GitProviderAuthError.invalidResponse } + let next = response.value(forHTTPHeaderField: "X-Next-Page") + let hasNext = account.provider == .gitlab && next != nil ? !(next?.isEmpty ?? true) + : response.value(forHTTPHeaderField: "Link")?.contains("rel=\"next\"") ?? false + return GitProviderRepositoryPage(repositories: repositories, hasNextPage: hasNext) + } + + private struct GitHubRepository: Decodable { + var full_name: String + var clone_url: String + var ssh_url: String + } + private struct GitLabRepository: Decodable { + var path_with_namespace: String + var http_url_to_repo: String + var ssh_url_to_repo: String + } +} diff --git a/macgit/Services/GitProviderTokenVault.swift b/macgit/Services/GitProviderTokenVault.swift index b158a1d3..11ab6e32 100644 --- a/macgit/Services/GitProviderTokenVault.swift +++ b/macgit/Services/GitProviderTokenVault.swift @@ -27,7 +27,7 @@ protocol GitProviderTokenVault { enum GitProviderTokenVaultKey { static func key(for account: GitProviderAccount) -> String { - let host = account.hostURL.host(percentEncoded: false) ?? account.hostURL.absoluteString + let host = GitProviderHost.identityKey(account.hostURL) return [ account.macgitUID, account.provider.rawValue, diff --git a/macgit/Services/GitRemoteIdentityResolver.swift b/macgit/Services/GitRemoteIdentityResolver.swift index 4fa82efd..f987e8f5 100644 --- a/macgit/Services/GitRemoteIdentityResolver.swift +++ b/macgit/Services/GitRemoteIdentityResolver.swift @@ -29,11 +29,37 @@ struct GitRemoteIdentity: Equatable { enum GitRemoteIdentityResolver { static func identity( from remoteURLString: String, - knownGitLabHosts: Set = [] + knownGitLabHosts: Set = [], + knownHosts: [GitProviderHost] = [] ) -> GitRemoteIdentity? { let trimmed = remoteURLString.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + let remote: URL? = { + if trimmed.contains("://") { return URL(string: trimmed) } + guard let at = trimmed.firstIndex(of: "@"), let colon = trimmed[at...].firstIndex(of: ":") else { return nil } + return URL(string: "ssh://" + trimmed[.. 1 { return nil } + if let remote, let server = knownHosts.sorted(by: { $0.baseURL.path.count > $1.baseURL.path.count }).first(where: { + let base = $0.normalized.baseURL + let path = base.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return base.host?.lowercased() == remote.host?.lowercased() + && (remote.scheme == "ssh" || (base.port ?? 443) == (remote.port ?? 443)) + && (remote.scheme == "ssh" || path.isEmpty || remote.path.hasPrefix("/" + path + "/")) + }) { + let base = server.normalized.baseURL + let prefix = base.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let parts = remote.path.split(separator: "/").dropFirst(!prefix.isEmpty && remote.path.hasPrefix("/" + prefix + "/") ? prefix.split(separator: "/").count : 0).map(String.init) + guard parts.count >= 2, server.kind == .gitlab || parts.count == 2 else { return nil } + let name = strippingGitSuffix(parts.last!) + guard !name.isEmpty else { return nil } + return GitRemoteIdentity(provider: server.kind, hostURL: base, + ownerPath: parts.dropLast().joined(separator: "/"), repositoryName: name, + canonicalHTTPSURL: base.appending(path: parts.dropLast().joined(separator: "/") + "/" + name + ".git")) + } + // Never reinterpret a configured server with a different port/path as a root installation. + if let remote, knownHosts.contains(where: { $0.baseURL.host?.lowercased() == remote.host?.lowercased() }) { return nil } let normalizedKnownGitLabHosts = Set(knownGitLabHosts.map { $0.lowercased() }) if let sshIdentity = identityFromScpLikeURL(trimmed, knownGitLabHosts: normalizedKnownGitLabHosts) { return sshIdentity diff --git a/macgit/Services/GitStatusService+Clone.swift b/macgit/Services/GitStatusService+Clone.swift index 1b9fa9c4..a0fbfe3d 100644 --- a/macgit/Services/GitStatusService+Clone.swift +++ b/macgit/Services/GitStatusService+Clone.swift @@ -77,10 +77,14 @@ extension GitStatusService { } } - func remoteBranches(remoteURL: String) async throws -> [String] { - let output = try await runGit( - arguments: ["ls-remote", "--heads", remoteURL], - in: FileManager.default.temporaryDirectory + func remoteBranches(remoteURL: String, credentialResolver: GitProviderCredentialResolver? = nil) async throws -> [String] { + let directory = FileManager.default.temporaryDirectory + let injection = try await credentialInjection(for: remoteURL, in: directory, credentialResolver: credentialResolver, + credentialInjector: TemporaryGitCredentialInjector(), sshCredentialInjector: TemporaryGitSSHCredentialInjector()) + defer { injection?.cleanup() } + let output = try await runRemoteGit( + arguments: ["ls-remote", "--heads", "--", remoteURL], + in: directory, injection: injection ) return Self.parseRemoteBranches(from: output) } diff --git a/macgit/Services/GitStatusService+RemoteCredential.swift b/macgit/Services/GitStatusService+RemoteCredential.swift index 729e701a..128d6764 100644 --- a/macgit/Services/GitStatusService+RemoteCredential.swift +++ b/macgit/Services/GitStatusService+RemoteCredential.swift @@ -43,13 +43,7 @@ extension GitStatusService { ) async throws -> GitCredentialInjection? { guard let credentialResolver else { return .configuredGitHelpers() } let remoteURLString: String - if GitRemoteIdentityResolver.identity( - from: remote, - knownGitLabHosts: Set(credentialResolver.accounts.compactMap { account in - guard account.provider == .gitlab else { return nil } - return account.hostURL.host(percentEncoded: false)?.lowercased() - }) - ) != nil { + if credentialResolver.remoteIdentity(for: remote) != nil { remoteURLString = remote } else { remoteURLString = await remoteURL(remote: remote, in: repositoryURL) diff --git a/macgit/Services/LocalGitProviderAccountStore.swift b/macgit/Services/LocalGitProviderAccountStore.swift index 7315f059..3d57540e 100644 --- a/macgit/Services/LocalGitProviderAccountStore.swift +++ b/macgit/Services/LocalGitProviderAccountStore.swift @@ -271,7 +271,7 @@ private struct GitProviderAccountLocalIdentity: Hashable { init(_ account: GitProviderAccount) { provider = account.provider - host = (account.hostURL.host(percentEncoded: false) ?? account.hostURL.absoluteString).lowercased() + host = GitProviderHost.identityKey(account.hostURL) providerUserID = account.providerUserID } } diff --git a/macgit/Views/Account/GitProviderAccountsPresentationPolicy.swift b/macgit/Views/Account/GitProviderAccountsPresentationPolicy.swift index c52d2d57..a23ccf50 100644 --- a/macgit/Views/Account/GitProviderAccountsPresentationPolicy.swift +++ b/macgit/Views/Account/GitProviderAccountsPresentationPolicy.swift @@ -66,21 +66,8 @@ enum GitProviderAccountsPresentationPolicy { } static func normalizedSelfHostedGitLabHost(from value: String) -> GitProviderHost? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - !trimmed.contains(" ") else { - return nil - } - - let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)" - guard let url = URL(string: candidate), - let host = url.host(percentEncoded: false), - !host.isEmpty, - host.lowercased() != "gitlab.com" else { - return nil - } - - return GitProviderHost(kind: .gitlab, baseURL: url).normalized + guard let host = GitProviderHost.configured(kind: .gitlab, value: value), host.baseURL != GitProviderHost.gitlabDotCom.baseURL else { return nil } + return host } static func accountCreationMessage( diff --git a/macgit/Views/Account/GitProviderAddAccountSheet.swift b/macgit/Views/Account/GitProviderAddAccountSheet.swift index 22d867a6..d195409d 100644 --- a/macgit/Views/Account/GitProviderAddAccountSheet.swift +++ b/macgit/Views/Account/GitProviderAddAccountSheet.swift @@ -28,6 +28,10 @@ struct GitProviderAddAccountSheet: View { @State private var selectedHost: GitProviderAddAccountHost = .github @State private var selectedAuthType: GitProviderAddAccountAuthType = .oauth @State private var selectedProtocol: GitProviderAddAccountProtocol = .https + @State private var isSelfHosted = false + @State private var serverURL = "" + @State private var personalAccessToken = "" + @State private var connectedAccountID: String? @State private var connectedUsername = "" @State private var bitbucketUsername = "" @State private var bitbucketAPIToken = "" @@ -42,9 +46,15 @@ struct GitProviderAddAccountSheet: View { self.controller = controller self.editingAccount = editingAccount self.accountCreationDecision = accountCreationDecision + if let account = editingAccount { + let publicHost = account.provider == .github ? GitProviderHost.githubDotCom : GitProviderHost.gitlabDotCom + _isSelfHosted = State(initialValue: account.provider != .bitbucket && account.hostURL != publicHost.baseURL) + _serverURL = State(initialValue: account.hostURL.absoluteString) + } _selectedHost = State(initialValue: editingAccount.map(GitProviderAddAccountPresentationPolicy.host(for:)) ?? .github) _selectedAuthType = State(initialValue: editingAccount?.provider == .bitbucket ? .personalAccessToken : .oauth) _selectedProtocol = State(initialValue: editingAccount?.transportProtocol == .ssh ? .ssh : .https) + _connectedAccountID = State(initialValue: editingAccount?.id) _connectedUsername = State(initialValue: editingAccount?.username ?? "") _bitbucketUsername = State(initialValue: editingAccount?.provider == .bitbucket ? editingAccount?.username ?? "" : "") } @@ -66,19 +76,57 @@ struct GitProviderAddAccountSheet: View { .disabled(editingAccount != nil) .onChange(of: selectedHost) { _, _ in connectedUsername = "" + connectedAccountID = nil + isSelfHosted = false + serverURL = "" + personalAccessToken = "" bitbucketUsername = "" bitbucketAPIToken = "" selectedAuthType = selectedHost == .bitbucket ? .personalAccessToken : .oauth } - Picker("Auth Type", selection: $selectedAuthType) { - ForEach(GitProviderAddAccountPresentationPolicy.authTypeOptions(for: selectedHost), id: \.id) { option in - Text(option.title) - .tag(option.id) - .disabled(!option.isEnabled) + if selectedHost != .bitbucket { + Toggle("Self-hosted server (Pro)", isOn: $isSelfHosted) + .disabled(editingAccount != nil || !controller.canConnectSelfHosted) + .onChange(of: isSelfHosted) { _, _ in + connectedUsername = "" + connectedAccountID = nil + personalAccessToken = "" + } + if !controller.canConnectSelfHosted { + Text("Connecting self-managed Git servers requires an active Pro plan.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if isSelfHosted { + TextField("Server URL", text: $serverURL, prompt: Text("https://git.company.com")) + .disabled(editingAccount != nil) + .onChange(of: serverURL) { _, _ in + connectedUsername = "" + connectedAccountID = nil + personalAccessToken = "" + } + if selectedProtocol == .https { + SecureField("Personal Access Token", text: $personalAccessToken) + Text(selectedHost == .github + ? "Use a token with access to the repository and required pull request permissions." + : "Use a personal access token with api scope for API access and Git operations.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } - .disabled(!GitProviderAddAccountPresentationPolicy.canSelectAuthType(for: selectedHost)) + + if !isSelfHosted { + Picker("Auth Type", selection: $selectedAuthType) { + ForEach(GitProviderAddAccountPresentationPolicy.authTypeOptions(for: selectedHost), id: \.id) { option in + Text(option.title) + .tag(option.id) + .disabled(!option.isEnabled) + } + } + .disabled(!GitProviderAddAccountPresentationPolicy.canSelectAuthType(for: selectedHost)) + } if selectedHost == .bitbucket { TextField("Username", text: $bitbucketUsername) @@ -128,6 +176,7 @@ struct GitProviderAddAccountSheet: View { } } .formStyle(.grouped) + .disabled(controller.isLoading) if let authorization = controller.pendingDeviceAuthorization { GitProviderDeviceAuthorizationView( @@ -181,7 +230,22 @@ struct GitProviderAddAccountSheet: View { .onDisappear(perform: cancelConnection) } + private var configuredHost: GitProviderHost? { + let kind: GitProviderKind = selectedHost == .github ? .github : selectedHost == .gitlab ? .gitlab : .bitbucket + if isSelfHosted { return GitProviderHost.configured(kind: kind, value: serverURL) } + switch kind { + case .github: return .githubDotCom + case .gitlab: return .gitlabDotCom + case .bitbucket: return .bitbucketDotOrg + } + } + private var canConnect: Bool { + if isSelfHosted { + return controller.canConnectSelfHosted && (editingAccount != nil || accountCreationDecision.isAllowed) && configuredHost != nil + && (selectedProtocol == .ssh ? !sshKeyPath.isEmpty : !personalAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + guard (editingAccount != nil || accountCreationDecision.isAllowed), GitProviderAddAccountPresentationPolicy.canConnect( host: selectedHost, @@ -224,43 +288,52 @@ struct GitProviderAddAccountSheet: View { guard canConnect else { return } connectionTask?.cancel() connectionTask = Task { @MainActor in - switch selectedHost { - case .github: - if let editingAccount { - await controller.reconnect(editingAccount) + if isSelfHosted, let host = configuredHost { + if selectedProtocol == .ssh { + await controller.connectSSH(host: host, key: GitProviderSSHKey(path: sshKeyPath), replacing: editingAccount) } else { - if selectedProtocol == .ssh { - await controller.connectSSH(host: .githubDotCom, key: GitProviderSSHKey(path: sshKeyPath)) + await controller.connectPersonalAccessToken(host: host, accessToken: personalAccessToken, replacing: editingAccount) + if controller.errorMessage == nil { personalAccessToken = "" } + } + } else { + switch selectedHost { + case .github: + if let editingAccount { + await controller.reconnect(editingAccount) } else { - await controller.connectGitHub() + if selectedProtocol == .ssh { + await controller.connectSSH(host: .githubDotCom, key: GitProviderSSHKey(path: sshKeyPath)) + } else { + await controller.connectGitHub() + } } - } - case .gitlab: - if let editingAccount { - await controller.reconnect(editingAccount) - } else { - if selectedProtocol == .ssh { - await controller.connectSSH(host: .gitlabDotCom, key: GitProviderSSHKey(path: sshKeyPath)) + case .gitlab: + if let editingAccount { + await controller.reconnect(editingAccount) } else { - await controller.connectGitLabDotCom() + if selectedProtocol == .ssh { + await controller.connectSSH(host: .gitlabDotCom, key: GitProviderSSHKey(path: sshKeyPath)) + } else { + await controller.connectGitLabDotCom() + } } - } - case .bitbucket: - if selectedProtocol == .ssh { - await controller.connectSSH( - host: .bitbucketDotOrg, - key: GitProviderSSHKey(path: sshKeyPath), - username: bitbucketUsername, - replacing: editingAccount - ) - } else { - await controller.connectBitbucket( - username: bitbucketUsername, - apiToken: bitbucketAPIToken, - replacing: editingAccount - ) - if controller.errorMessage == nil { - bitbucketAPIToken = "" + case .bitbucket: + if selectedProtocol == .ssh { + await controller.connectSSH( + host: .bitbucketDotOrg, + key: GitProviderSSHKey(path: sshKeyPath), + username: bitbucketUsername, + replacing: editingAccount + ) + } else { + await controller.connectBitbucket( + username: bitbucketUsername, + apiToken: bitbucketAPIToken, + replacing: editingAccount + ) + if controller.errorMessage == nil { + bitbucketAPIToken = "" + } } } } @@ -270,19 +343,17 @@ struct GitProviderAddAccountSheet: View { } private func refreshConnectedUsername() { - connectedUsername = matchingAccount()?.username ?? "" + guard controller.errorMessage == nil, let host = configuredHost else { return } + let account = controller.accounts.last { $0.provider == host.kind && GitProviderHost.identityKey($0.hostURL) == GitProviderHost.identityKey(host.baseURL) } + connectedAccountID = account?.id + connectedUsername = account?.username ?? "" } private func matchingAccount() -> GitProviderAccount? { - controller.accounts.first { account in - switch selectedHost { - case .github: - return account.provider == .github - case .gitlab: - return account.provider == .gitlab && account.hostURL.host(percentEncoded: false) == "gitlab.com" - case .bitbucket: - return account.provider == .bitbucket - } + guard let host = configuredHost else { return nil } + return controller.accounts.first { account in + account.id == connectedAccountID && account.provider == host.kind && GitProviderHost.identityKey(account.hostURL) == GitProviderHost.identityKey(host.baseURL) + && (editingAccount == nil || account.providerUserID == editingAccount?.providerUserID) } } diff --git a/macgit/Views/Common/GitProviderRepositoryBrowser.swift b/macgit/Views/Common/GitProviderRepositoryBrowser.swift new file mode 100644 index 00000000..97a0d134 --- /dev/null +++ b/macgit/Views/Common/GitProviderRepositoryBrowser.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct GitProviderRepositoryBrowser: View { + var resolver: GitProviderCredentialResolver + var onSelect: (String, String) -> Void + @State private var selectedAccountID = "" + @State private var repositories: [GitProviderDiscoveredRepository] = [] + @State private var page = 1 + @State private var hasNextPage = false + @State private var retryID = 0 + @State private var loading = false + @State private var errorMessage: String? + + private var accounts: [GitProviderAccount] { + resolver.accounts.filter { $0.provider != .bitbucket && ($0.transportProtocol == .https || $0.permissions["authentication"] == "personalAccessToken" || !$0.scopes.isEmpty) } + } + + var body: some View { + if !accounts.isEmpty { + DisclosureGroup("Browse connected repositories") { + Picker("Account", selection: $selectedAccountID) { + Text("Choose account").tag("") + ForEach(accounts) { account in + Text("\(account.username) — \(GitProviderHost.identityKey(account.hostURL))").tag(account.id) + } + } + .onChange(of: selectedAccountID) { _, _ in repositories = []; page = 1; hasNextPage = false; errorMessage = nil } + if loading { ProgressView("Loading repositories...").controlSize(.small) } + if !repositories.isEmpty { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(repositories) { repository in + Button(repository.name) { onSelect(repository.cloneURL, selectedAccountID) } + .buttonStyle(.link) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 130) + } + if hasNextPage && errorMessage == nil { Button("Load more") { page += 1 }.disabled(loading) } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) + Button("Retry") { retryID += 1 }.disabled(loading) + } else if !selectedAccountID.isEmpty && !loading && repositories.isEmpty { + Text("No repositories available for this account.").foregroundStyle(.secondary) + } + } + .task(id: "\(selectedAccountID):\(page):\(retryID)") { + await loadRepositories() + } + } + } + + private func loadRepositories() async { + guard let account = accounts.first(where: { $0.id == selectedAccountID }) else { loading = false; return } + loading = true + defer { if !Task.isCancelled { loading = false } } + do { + let result = try await GitProviderRepositoryDiscoveryService().repositories(account: account, resolver: resolver, page: page) + try Task.checkCancellation() + repositories += result.repositories.filter { new in !repositories.contains(where: { $0.id == new.id }) } + hasNextPage = result.hasNextPage + errorMessage = nil + } catch { + guard !Task.isCancelled else { return } + errorMessage = GitProviderAuthError.connectionMessage(error) + } + } +} diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index d88d2153..44074474 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -1076,6 +1076,7 @@ private struct RepoPickerCountBadge: View { struct CloneSheetView: View { @Environment(\.gitLFSCredentialResolver) private var lfsCredentialResolver @Environment(\.dismiss) private var dismiss + @State private var discoveredAccountID: String? @State private var remoteURL: String @State private var destinationPath = "" @State private var repositoryName: String @@ -1121,6 +1122,14 @@ struct CloneSheetView: View { .font(.largeTitle) .bold() + if let resolver = lfsCredentialResolver { + GitProviderRepositoryBrowser(resolver: resolver) { url, accountID in + discoveredAccountID = accountID + remoteURL = url + } + .disabled(isCloning) + } + VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 12) { Text("Source URL:") @@ -1285,6 +1294,15 @@ struct CloneSheetView: View { } } + private var cloneCredentialResolver: GitProviderCredentialResolver? { + guard var resolver = lfsCredentialResolver else { return nil } + if let discoveredAccountID, let identity = resolver.remoteIdentity(for: remoteURL), + resolver.matchingAccounts(for: remoteURL).contains(where: { $0.id == discoveredAccountID }) { + resolver.preferredAccountIDsByRemoteIdentity[GitProviderAccountPreferenceKey.make(for: identity)] = discoveredAccountID + } + return resolver + } + private var canClone: Bool { !isCloning && !remoteURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1359,7 +1377,7 @@ struct CloneSheetView: View { do { try await Task.sleep(nanoseconds: 500_000_000) - let branches = try await GitStatusService.shared.remoteBranches(remoteURL: sourceURL) + let branches = try await GitStatusService.shared.remoteBranches(remoteURL: sourceURL, credentialResolver: cloneCredentialResolver) guard !Task.isCancelled else { return } remoteBranches = branches remoteBranchLoadError = nil @@ -1408,7 +1426,7 @@ struct CloneSheetView: View { checkoutBranch: checkoutBranch, recurseSubmodules: recurseSubmodules, downloadLFSContent: downloadLFSContent, - credentialResolver: lfsCredentialResolver + credentialResolver: cloneCredentialResolver ) await MainActor.run { @@ -1437,7 +1455,7 @@ struct CloneSheetView: View { guard lfsRuntime.status?.activeRuntime != nil else { throw GitError.commandFailed(lfsRuntime.error ?? "Git LFS installation was cancelled.") } - try await GitStatusService.shared.finishLFSClone(in: repository, credentialResolver: lfsCredentialResolver) + try await GitStatusService.shared.finishLFSClone(in: repository, credentialResolver: cloneCredentialResolver) onClone(repository) dismiss() } catch { diff --git a/macgitTests/GitProviderAccountControllerTests.swift b/macgitTests/GitProviderAccountControllerTests.swift index 17216396..f1a37a8b 100644 --- a/macgitTests/GitProviderAccountControllerTests.swift +++ b/macgitTests/GitProviderAccountControllerTests.swift @@ -21,6 +21,46 @@ import XCTest @MainActor final class GitProviderAccountControllerTests: XCTestCase { + func testFreeUserCannotConnectFirstSelfManagedAccountUsingPAT() async { + let controller = GitProviderAccountController(store: FakeGitProviderAccountStore(), tokenVault: FakeGitProviderTokenVault()) + await controller.connectPersonalAccessToken(host: GitProviderHost(kind: .github, baseURL: URL(string: "https://source.company.test")!), accessToken: "test-token") + XCTAssertFalse(controller.canConnectSelfHosted) + XCTAssertTrue(controller.accounts.isEmpty) + XCTAssertEqual(controller.errorMessage, GitProviderAccountAccessError.selfHostedRequiresPro.localizedDescription) + } + + func testFreeUserCannotConnectSelfManagedGitLabUsingOAuth() async { + let controller = GitProviderAccountController(store: FakeGitProviderAccountStore(), tokenVault: FakeGitProviderTokenVault()) + await controller.connectSelfHostedGitLab(hostURL: URL(string: "https://source.company.test")!) + XCTAssertEqual(controller.errorMessage, GitProviderAccountAccessError.selfHostedRequiresPro.localizedDescription) + } + + func testFreeUserCannotConnectSelfManagedSSHBeforeAuthentication() async { + let ssh = FakeGitProviderSSHAuthService(username: "user") + let controller = GitProviderAccountController(store: FakeGitProviderAccountStore(), tokenVault: FakeGitProviderTokenVault(), sshAuthService: ssh) + await controller.connectSSH(host: GitProviderHost(kind: .gitlab, baseURL: URL(string: "https://source.company.test")!), key: GitProviderSSHKey(path: "/test/key")) + XCTAssertTrue(ssh.requests.isEmpty) + XCTAssertEqual(controller.errorMessage, GitProviderAccountAccessError.selfHostedRequiresPro.localizedDescription) + } + + func testProUserCanConnectSelfManagedSSH() async { + let ssh = FakeGitProviderSSHAuthService(username: "user") + let controller = GitProviderAccountController(store: FakeGitProviderAccountStore(), tokenVault: FakeGitProviderTokenVault(), sshKeyStore: FakeGitProviderSSHKeyStore(), sshAuthService: ssh, hasProAccess: { true }) + await controller.connectSSH(host: GitProviderHost(kind: .gitlab, baseURL: URL(string: "https://source.company.test")!), key: GitProviderSSHKey(path: "/test/key")) + XCTAssertEqual(ssh.requests.count, 1) + XCTAssertNil(controller.errorMessage) + XCTAssertEqual(controller.accounts.count, 1) + } + + func testSelfManagedAccessTracksCurrentProEntitlement() { + var hasPro = true + let controller = GitProviderAccountController(store: FakeGitProviderAccountStore(), tokenVault: FakeGitProviderTokenVault(), hasProAccess: { hasPro }) + XCTAssertTrue(controller.canConnectSelfHosted) + hasPro = false + XCTAssertFalse(controller.canConnectSelfHosted) + } + + func testSignedOutStateLoadsLocalProviderAccounts() async { let account = makeProviderAccount(macgitUID: "local-owner") let vault = FakeGitProviderTokenVault(tokensByAccountID: [ diff --git a/macgitTests/GitProviderAccountsSectionTests.swift b/macgitTests/GitProviderAccountsSectionTests.swift index 9fbca46f..62302e96 100644 --- a/macgitTests/GitProviderAccountsSectionTests.swift +++ b/macgitTests/GitProviderAccountsSectionTests.swift @@ -264,7 +264,7 @@ final class GitProviderAccountsSectionTests: XCTestCase { ) XCTAssertEqual(host.kind, .gitlab) - XCTAssertEqual(host.baseURL.absoluteString, "https://gitlab.example.com") + XCTAssertEqual(host.baseURL.absoluteString, "https://gitlab.example.com/gitlab") } func testGitLabAccountUsesSameDisconnectFlowAsGitHub() { diff --git a/macgitTests/GitProviderTokenVaultTests.swift b/macgitTests/GitProviderTokenVaultTests.swift index 5963a727..8cda9983 100644 --- a/macgitTests/GitProviderTokenVaultTests.swift +++ b/macgitTests/GitProviderTokenVaultTests.swift @@ -46,7 +46,7 @@ final class GitProviderTokenVaultTests: XCTestCase { XCTAssertEqual( GitProviderTokenVaultKey.key(for: account), - "macgit-user-1:github:github.com:provider-user-42" + "macgit-user-1:github:github.com/path:provider-user-42" ) } diff --git a/macgitTests/SelfHostedGitProviderTests.swift b/macgitTests/SelfHostedGitProviderTests.swift new file mode 100644 index 00000000..684bf42a --- /dev/null +++ b/macgitTests/SelfHostedGitProviderTests.swift @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +final class SelfHostedGitProviderTests: XCTestCase { + func testConfiguredServersPreservePortAndGitLabSubpath() throws { + let server = try XCTUnwrap(GitProviderHost.configured(kind: .gitlab, value: " source.company.test:8443/gitlab/ ")) + XCTAssertEqual(server.baseURL.absoluteString, "https://source.company.test:8443/gitlab") + XCTAssertFalse(GitProviderHost.accountHostIdentifier(server.baseURL).contains("/")) + XCTAssertEqual(server.apiURL.absoluteString, "https://source.company.test:8443/gitlab/api/v4") + for value in ["http://source.test", "ftp://source.test", "https://user:secret@source.test", "https://source.test?token=secret", "https://source.test/#fragment", "bad host"] { + XCTAssertNil(GitProviderHost.configured(kind: .gitlab, value: value)) + } + XCTAssertNil(GitProviderHost.configured(kind: .github, value: "https://source.test/unexpected")) + XCTAssertEqual(GitProviderHost.githubDotCom.apiURL.absoluteString, "https://api.github.com") + XCTAssertEqual(GitProviderHost.gitlabDotCom.apiURL.absoluteString, "https://gitlab.com/api/v4") + } + + func testEnterpriseHTTPSAndSSHRecognizedWithoutProviderNameInHostname() throws { + let server = try XCTUnwrap(GitProviderHost.configured(kind: .github, value: "https://source.company.test")) + for remote in ["https://source.company.test/team/project.git", "git@source.company.test:team/project.git", "ssh://git@source.company.test:2222/team/project.git"] { + let identity = try XCTUnwrap(GitRemoteIdentityResolver.identity(from: remote, knownHosts: [server])) + XCTAssertEqual(identity.provider, .github) + XCTAssertEqual(identity.ownerPath, "team") + XCTAssertEqual(identity.hostURL, server.baseURL) + } + XCTAssertNil(GitRemoteIdentityResolver.identity(from: "https://source.company.test:8443/team/project.git", knownHosts: [server])) + } + + func testGitLabSubpathExcludedFromProjectNamespace() throws { + let server = try XCTUnwrap(GitProviderHost.configured(kind: .gitlab, value: "https://source.company.test:8443/gitlab")) + let identity = try XCTUnwrap(GitRemoteIdentityResolver.identity(from: "https://source.company.test:8443/gitlab/team/subgroup/project.git", knownHosts: [server])) + XCTAssertEqual(identity.ownerPath, "team/subgroup") + XCTAssertEqual(identity.canonicalHTTPSURL.absoluteString, "https://source.company.test:8443/gitlab/team/subgroup/project.git") + XCTAssertNil(GitRemoteIdentityResolver.identity(from: "https://source.company.test:8443/other/team/project.git", knownHosts: [server])) + let ssh = try XCTUnwrap(GitRemoteIdentityResolver.identity(from: "git@source.company.test:team/project.git", knownHosts: [server])) + XCTAssertEqual(ssh.ownerPath, "team") + } + + func testVaultAndCredentialMatchingIsolateInstallations() async throws { + let root = account(.gitlab, "https://source.test") + let subpath = account(.gitlab, "https://source.test/gitlab") + let port = account(.gitlab, "https://source.test:8443") + XCTAssertEqual(Set([root, subpath, port].map(GitProviderTokenVaultKey.key)).count, 3) + let resolver = GitProviderCredentialResolver(accounts: [root, subpath, port], tokenVault: TestVault()) + XCTAssertEqual(resolver.matchingAccounts(for: "https://source.test:8443/team/project.git").map(\.id), [port.id]) + XCTAssertEqual(resolver.matchingAccounts(for: "https://source.test/gitlab/team/project.git").map(\.id), [subpath.id]) + let credential = try await resolver.credential(for: "https://source.test:9443/team/project.git") + XCTAssertNil(credential) + } + + func testEnterpriseProfileValidationUsesConfiguredAPI() async throws { + let client = Client(body: #"{"id":42,"login":"enterprise-user"}"#) + let service = GitHubProviderAuthService(configuration: .appConfiguration(), httpClient: client) + let host = GitProviderHost(kind: .github, baseURL: URL(string: "https://source.test:8443")!) + let account = try await service.fetchAccount(token: token, macgitUID: "local", host: host) + XCTAssertEqual(client.requests.first?.url?.absoluteString, "https://source.test:8443/api/v3/user") + XCTAssertEqual(account.hostURL, host.baseURL) + } + + func testDiscoveryUsesGitLabSubpathAndPaginates() async throws { + let client = Client(body: #"[{"path_with_namespace":"team/project","http_url_to_repo":"https://source.test/gitlab/team/project.git","ssh_url_to_repo":"git@source.test:team/project.git"}]"#, headers: ["X-Next-Page": "3"]) + let result = try await GitProviderRepositoryDiscoveryService(httpClient: client).repositories(account: account(.gitlab, "https://source.test/gitlab"), token: token, page: 2) + let request = try XCTUnwrap(client.requests.first) + XCTAssertEqual(request.url?.path, "/gitlab/api/v4/projects") + XCTAssertTrue(request.url?.query?.contains("page=2") == true) + XCTAssertTrue(request.url?.query?.contains("membership=true") == true) + XCTAssertTrue(result.hasNextPage) + XCTAssertEqual(result.repositories.first?.name, "team/project") + } + + func testEnterpriseDiscoveryAndVisibilityStayOnEnterpriseServer() async throws { + let client = Client(body: #"[{"full_name":"team/project","clone_url":"https://source.test/team/project.git","ssh_url":"git@source.test:team/project.git"}]"#, headers: ["Link": "; rel=\"next\""]) + let result = try await GitProviderRepositoryDiscoveryService(httpClient: client).repositories(account: account(.github, "https://source.test"), token: token, page: 1) + XCTAssertEqual(client.requests.first?.url?.path, "/api/v3/user/repos") + XCTAssertTrue(result.hasNextPage) + let visibilityClient = Client(body: #"{"private":true}"#) + let visibility = try await GitHubRepositoryVisibilityService(httpClient: visibilityClient).visibility(for: GitRepositoryIdentity(provider: .github, hostURL: URL(string: "https://source.test")!, owner: "team", name: "project"), token: token) + XCTAssertEqual(visibility, .private) + XCTAssertEqual(visibilityClient.requests.first?.url?.absoluteString, "https://source.test/api/v3/repos/team/project") + } + + func testDiscoveryRejectsExpiredToken() async { + let client = Client(body: "{}", status: 401) + do { + _ = try await GitProviderRepositoryDiscoveryService(httpClient: client).repositories(account: account(.github, "https://source.test"), token: token, page: 1) + XCTFail("Expected token rejection") + } catch { XCTAssertTrue(error.localizedDescription.contains("invalid or expired")) } + } + + private var token: GitProviderToken { GitProviderToken(accessToken: "test-token", tokenType: "Bearer") } + private func account(_ provider: GitProviderKind, _ url: String) -> GitProviderAccount { + GitProviderAccount(id: url, macgitUID: "local", provider: provider, hostURL: URL(string: url)!, providerUserID: "42", username: "user", displayName: nil, avatarURL: nil, scopes: [], permissions: ["authentication": "personalAccessToken"], tokenStatus: .valid, connectedAt: Date(), lastValidatedAt: nil) + } +} + +private final class Client: GitProviderHTTPClient { + var requests: [URLRequest] = [] + let body: String + let headers: [String: String] + let status: Int + init(body: String, headers: [String: String] = [:], status: Int = 200) { self.body = body; self.headers = headers; self.status = status } + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + return (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)!) + } +} + +private struct TestVault: GitProviderTokenVault { + func readToken(for account: GitProviderAccount) throws -> GitProviderToken? { GitProviderToken(accessToken: "test-token", tokenType: "Bearer") } + func saveToken(_ token: GitProviderToken, for account: GitProviderAccount) throws {} + func deleteToken(for account: GitProviderAccount) throws {} +} From b1c87ee369ef2554694079eeb22571d8d54ad437 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 3 Oct 2026 14:08:34 +0700 Subject: [PATCH 2/2] fix: Migrate legacy provider tokens and handle GitLab PAT auth Preserve case in token vault keys, migrate portless legacy tokens when a unique owner exists, and keep GitLab personal access tokens from requiring reauthorization. Reset the discovered account when the clone source URL is edited. --- macgit/App/GitProviderAccountController.swift | 2 + macgit/Services/GitProviderTokenVault.swift | 44 ++++++++++++++++++- macgit/Views/MainWindow/RepoPickerView.swift | 14 +++++- .../GitLabProviderAuthServiceTests.swift | 6 +-- .../GitProviderAccountControllerTests.swift | 14 ++++++ macgitTests/SelfHostedGitProviderTests.swift | 14 +++++- 6 files changed, 87 insertions(+), 7 deletions(-) diff --git a/macgit/App/GitProviderAccountController.swift b/macgit/App/GitProviderAccountController.swift index 63b4f180..8dbaa4b6 100644 --- a/macgit/App/GitProviderAccountController.swift +++ b/macgit/App/GitProviderAccountController.swift @@ -252,6 +252,7 @@ final class GitProviderAccountController: ObservableObject { } return account } + try tokenVault.migrateLegacyToken(for: account, among: storedAccounts) guard try tokenVault.readToken(for: account) != nil else { var unavailableAccount = account unavailableAccount.tokenStatus = .unavailableOnThisDevice @@ -259,6 +260,7 @@ final class GitProviderAccountController: ObservableObject { } if account.provider == .gitlab, account.transportProtocol == .https, + account.permissions["authentication"] != "personalAccessToken", !account.scopes.contains("write_repository") { var accountRequiringAuthorization = account accountRequiringAuthorization.tokenStatus = .reauthorizationRequired diff --git a/macgit/Services/GitProviderTokenVault.swift b/macgit/Services/GitProviderTokenVault.swift index 11ab6e32..de93ee07 100644 --- a/macgit/Services/GitProviderTokenVault.swift +++ b/macgit/Services/GitProviderTokenVault.swift @@ -21,20 +21,52 @@ import Security protocol GitProviderTokenVault { func readToken(for account: GitProviderAccount) throws -> GitProviderToken? + func migrateLegacyToken(for account: GitProviderAccount, among accounts: [GitProviderAccount]) throws func saveToken(_ token: GitProviderToken, for account: GitProviderAccount) throws func deleteToken(for account: GitProviderAccount) throws } +extension GitProviderTokenVault { + func migrateLegacyToken(for account: GitProviderAccount, among accounts: [GitProviderAccount]) throws {} +} + enum GitProviderTokenVaultKey { static func key(for account: GitProviderAccount) -> String { let host = GitProviderHost.identityKey(account.hostURL) return [ account.macgitUID, account.provider.rawValue, - host.lowercased(), + host, account.providerUserID, ].joined(separator: ":") } + + static func legacyAccountToMigrate( + for account: GitProviderAccount, + among accounts: [GitProviderAccount] + ) -> GitProviderAccount? { + guard account.provider == .gitlab, + account.transportProtocol == .https, + account.permissions["authentication"] != "personalAccessToken", + let hostname = account.hostURL.host(percentEncoded: false), + var components = URLComponents(url: account.hostURL, resolvingAgainstBaseURL: false) else { + return nil + } + // The old key omitted ports and paths. Never guess its owner when installations collide. + let owners = accounts.filter { + $0.macgitUID == account.macgitUID && $0.provider == account.provider + && $0.providerUserID == account.providerUserID + && $0.hostURL.host(percentEncoded: false)?.lowercased() == hostname.lowercased() + } + guard owners.count == 1 else { return nil } + components.port = nil + components.path = "" + guard let legacyURL = components.url else { return nil } + var legacyAccount = account + legacyAccount.hostURL = legacyURL + guard key(for: legacyAccount) != key(for: account) else { return nil } + return legacyAccount + } } struct GitProviderTokenVaultError: LocalizedError { @@ -53,6 +85,16 @@ final class KeychainGitProviderTokenVault: GitProviderTokenVault { private let encoder = JSONEncoder() private let decoder = JSONDecoder() + func migrateLegacyToken(for account: GitProviderAccount, among accounts: [GitProviderAccount]) throws { + guard let legacyAccount = GitProviderTokenVaultKey.legacyAccountToMigrate(for: account, among: accounts) else { return } + if try readToken(for: account) == nil, let token = try readToken(for: legacyAccount) { + try saveToken(token, for: account) + } + if try readToken(for: account) != nil { + try deleteToken(for: legacyAccount) + } + } + func readToken(for account: GitProviderAccount) throws -> GitProviderToken? { let cacheKey = GitProviderTokenVaultKey.key(for: account) if let cachedToken = cachedToken(for: cacheKey) { diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index 44074474..22440686 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -1135,7 +1135,7 @@ struct CloneSheetView: View { Text("Source URL:") .frame(width: Self.labelWidth, alignment: .trailing) - TextField("https://github.com/user/repo.git", text: $remoteURL) + TextField("https://github.com/user/repo.git", text: remoteURLBinding) .textFieldStyle(.roundedBorder) .frame(height: Self.controlHeight) @@ -1257,7 +1257,7 @@ struct CloneSheetView: View { } .padding(30) .frame(minWidth: 680) - .task(id: trimmedRemoteURL) { + .task(id: [trimmedRemoteURL, discoveredAccountID ?? ""]) { await loadRemoteBranches(for: trimmedRemoteURL) } .alert("Error", isPresented: $showingError, actions: { @@ -1349,6 +1349,16 @@ struct CloneSheetView: View { || isCloning } + private var remoteURLBinding: Binding { + Binding( + get: { remoteURL }, + set: { newValue in + discoveredAccountID = nil + remoteURL = newValue + } + ) + } + private var repositoryNameBinding: Binding { Binding( get: { repositoryName }, diff --git a/macgitTests/GitLabProviderAuthServiceTests.swift b/macgitTests/GitLabProviderAuthServiceTests.swift index a63c8d44..22363c54 100644 --- a/macgitTests/GitLabProviderAuthServiceTests.swift +++ b/macgitTests/GitLabProviderAuthServiceTests.swift @@ -214,9 +214,9 @@ final class GitLabProviderAuthServiceTests: XCTestCase { host: host ) - XCTAssertEqual(account.id, "macgit-user-1:gitlab:gitlab.example.com:99") - XCTAssertEqual(account.hostURL.absoluteString, "https://gitlab.example.com") - XCTAssertEqual(client.requests.first?.url?.absoluteString, "https://gitlab.example.com/api/v4/user") + XCTAssertEqual(account.id, "macgit-user-1:gitlab:gitlab.example.com%2Fgitlab:99") + XCTAssertEqual(account.hostURL.absoluteString, "https://gitlab.example.com/gitlab") + XCTAssertEqual(client.requests.first?.url?.absoluteString, "https://gitlab.example.com/gitlab/api/v4/user") } func testUnauthorizedMapsToReauthorizationRequired() async throws { diff --git a/macgitTests/GitProviderAccountControllerTests.swift b/macgitTests/GitProviderAccountControllerTests.swift index f1a37a8b..52147399 100644 --- a/macgitTests/GitProviderAccountControllerTests.swift +++ b/macgitTests/GitProviderAccountControllerTests.swift @@ -127,6 +127,20 @@ final class GitProviderAccountControllerTests: XCTestCase { XCTAssertEqual(controller.accounts.first?.tokenStatus, .valid) } + func testGitLabPersonalAccessTokenRemainsValidAfterReload() async { + var account = makeProviderAccount(macgitUID: "macgit-user-1", provider: .gitlab) + account.permissions["authentication"] = "personalAccessToken" + let store = FakeGitProviderAccountStore(accountsByUID: ["macgit-user-1": [account]]) + let vault = FakeGitProviderTokenVault(tokensByAccountID: [ + account.id: GitProviderToken(accessToken: "token", tokenType: "Bearer") + ]) + let controller = GitProviderAccountController(store: store, tokenVault: vault) + + await controller.updateMacgitAccount(makeMacgitAccount(uid: "macgit-user-1")) + + XCTAssertEqual(controller.accounts.first?.tokenStatus, .valid) + } + func testLegacyGitLabOAuthAccountRequiresReauthorizationForPushScope() async { let account = makeProviderAccount(macgitUID: "macgit-user-1", provider: .gitlab) let store = FakeGitProviderAccountStore(accountsByUID: ["macgit-user-1": [account]]) diff --git a/macgitTests/SelfHostedGitProviderTests.swift b/macgitTests/SelfHostedGitProviderTests.swift index 684bf42a..e834e4af 100644 --- a/macgitTests/SelfHostedGitProviderTests.swift +++ b/macgitTests/SelfHostedGitProviderTests.swift @@ -41,7 +41,8 @@ final class SelfHostedGitProviderTests: XCTestCase { let root = account(.gitlab, "https://source.test") let subpath = account(.gitlab, "https://source.test/gitlab") let port = account(.gitlab, "https://source.test:8443") - XCTAssertEqual(Set([root, subpath, port].map(GitProviderTokenVaultKey.key)).count, 3) + let caseSensitivePath = account(.gitlab, "https://source.test/GitLab") + XCTAssertEqual(Set([root, subpath, port, caseSensitivePath].map(GitProviderTokenVaultKey.key)).count, 4) let resolver = GitProviderCredentialResolver(accounts: [root, subpath, port], tokenVault: TestVault()) XCTAssertEqual(resolver.matchingAccounts(for: "https://source.test:8443/team/project.git").map(\.id), [port.id]) XCTAssertEqual(resolver.matchingAccounts(for: "https://source.test/gitlab/team/project.git").map(\.id), [subpath.id]) @@ -49,6 +50,17 @@ final class SelfHostedGitProviderTests: XCTestCase { XCTAssertNil(credential) } + func testLegacyMigrationRequiresUniqueOwnerAndLeavesSSHAndPATAlone() throws { + var legacy = account(.gitlab, "https://source.test:8443") + legacy.permissions = [:] + let migrated = try XCTUnwrap(GitProviderTokenVaultKey.legacyAccountToMigrate(for: legacy, among: [legacy])) + XCTAssertEqual(GitProviderTokenVaultKey.key(for: migrated), "local:gitlab:source.test:42") + XCTAssertNil(GitProviderTokenVaultKey.legacyAccountToMigrate(for: legacy, among: [legacy, account(.gitlab, "https://source.test")])) + XCTAssertNil(GitProviderTokenVaultKey.legacyAccountToMigrate(for: account(.gitlab, "https://source.test:8443"), among: [legacy])) + legacy.transportProtocol = .ssh + XCTAssertNil(GitProviderTokenVaultKey.legacyAccountToMigrate(for: legacy, among: [legacy])) + } + func testEnterpriseProfileValidationUsesConfiguredAPI() async throws { let client = Client(body: #"{"id":42,"login":"enterprise-user"}"#) let service = GitHubProviderAuthService(configuration: .appConfiguration(), httpClient: client)