Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
34 changes: 34 additions & 0 deletions docs/self-hosted-git-providers.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 70 additions & 5 deletions macgit/App/GitProviderAccountController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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)
}
Expand All @@ -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,
Expand Down Expand Up @@ -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 = []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try await saveAuthorizedAccount(account, token: token)
} catch {
errorMessage = GitProviderAuthError.connectionMessage(error)
}
}

func connectBitbucket(
username: String,
apiToken: String,
Expand Down Expand Up @@ -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()
Expand All @@ -202,13 +252,15 @@ 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
return unavailableAccount
}
if account.provider == .gitlab,
account.transportProtocol == .https,
account.permissions["authentication"] != "personalAccessToken",
!account.scopes.contains("write_repository") {
var accountRequiringAuthorization = account
accountRequiringAuthorization.tokenStatus = .reauthorizationRequired
Expand Down Expand Up @@ -301,7 +353,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
}
Expand Down Expand Up @@ -384,6 +436,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
}
Expand Down Expand Up @@ -620,17 +673,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
}

Expand All @@ -649,8 +704,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
}

Expand All @@ -665,7 +719,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
Expand Down
7 changes: 4 additions & 3 deletions macgit/App/PullRequestController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions macgit/App/RepositoryVisibilityController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -203,6 +204,6 @@ final class RepositoryVisibilityController: ObservableObject {
}

private func normalizedHost(_ url: URL) -> String {
(url.host(percentEncoded: false) ?? url.absoluteString).lowercased()
GitProviderHost.identityKey(url)
}
}
2 changes: 2 additions & 0 deletions macgit/App/macgitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion macgit/Models/GitProviderAccountModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions macgit/Models/GitProviderDiscoveredRepository.swift
Original file line number Diff line number Diff line change
@@ -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 }
}

7 changes: 7 additions & 0 deletions macgit/Models/GitProviderRepositoryPage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

struct GitProviderRepositoryPage {
var repositories: [GitProviderDiscoveredRepository]
var hasNextPage: Bool
}

Loading
Loading