Architecture

git-pont should be a small integration library with a stable provider-neutral contract and provider-specific modules. The central idea is that apps talk to normalized concepts: connection, repository, branch, file reference, commit, and pull request. Provider modules translate those concepts to GitHub, GitLab, Forgejo, or Gitea APIs. Codeberg is a preset Forgejo instance, not a separate provider kind.

The v1 implementation is the Swift library, exposed as a Swift Package for Apple platforms. The architecture should still describe concepts that can be mirrored later by Android, web, or server libraries. Swift signatures in this document are canonical for v1 Swift; future libraries should translate them idiomatically while preserving behavior and field meanings.

Design Principles

  • Provider-neutral public API and models.
  • Provider-specific behavior hidden behind protocol implementations.
  • Async/await first.
  • Dependency injection for networking, credentials, clock, and logging.
  • No UI dependencies in core modules.
  • No global singleton requirement.
  • File content is raw Data, not String.
  • Preserve remote version identifiers for conflict detection.
  • Prefer explicit capabilities over assuming every provider supports every feature.
  • Keep model semantics portable to Kotlin/TypeScript-style data models.

Facade

GitPont is the app-facing entry point. This is the canonical public surface; integration docs and the README must match these signatures exactly.

public final class GitPont: Sendable {
    public init(
        providers: [GitProvider],
        connectionStore: ConnectionStore,
        credentialStore: CredentialStore,
        httpClient: HTTPClient
    )

    // URL handling
    public func parse(url: URL) throws -> GitURLParseResult
    public func resolve(_ result: GitURLParseResult) async throws -> GitURLReference

    // Files
    public func openFile(from url: URL) async throws -> GitRemoteFile
    public func readFile(_ reference: GitFileReference) async throws -> GitRemoteFile
    public func listDirectory(_ reference: GitFileReference) async throws -> GitList<GitDirectoryEntry>
    public func commitFile(_ change: GitFileChange) async throws -> GitCommitResult
    public func deleteFile(_ request: GitFileDeleteRequest) async throws -> GitCommitResult
    public func checkForRemoteChange(_ file: GitRemoteFile) async throws -> Bool

    // Repositories and branches
    public func repositories(connectionID: String) async throws -> GitList<GitRepository>
    public func repository(_ reference: GitRepositoryReference) async throws -> GitRepository
    public func branches(of repository: GitRepositoryReference) async throws -> GitList<GitBranch>
    public func createRepository(_ request: GitCreateRepositoryRequest, connectionID: String) async throws -> GitRepository
    public func createBranch(_ request: GitCreateBranchRequest) async throws -> GitBranch
    public func deleteBranch(_ request: GitDeleteBranchRequest) async throws
    public func forkRepository(_ reference: GitRepositoryReference, connectionID: String) async throws -> GitRepository

    // Pull requests and orchestration
    public func createPullRequest(_ request: GitPullRequestRequest) async throws -> GitPullRequest
    public func submitChange(_ submission: GitChangeSubmission) async throws -> GitChangeResult

    // OAuth
    public func startOAuth(_ request: GitOAuthStartRequest) async throws -> GitOAuthStartResult
    public func completeOAuth(_ request: GitOAuthCompletionRequest) async throws -> GitCredential

    // Connections
    public func connections() async throws -> [GitConnection]
    public func connection(for instance: GitProviderInstance, preferredConnectionID: String?) async throws -> GitConnection
    public func addConnection(instance: GitProviderInstance, credential: GitCredential, authMethod: GitAuthMethod) async throws -> GitConnection
    public func removeConnection(id: String) async throws
}

Convenience overload used by simple consumers:

public extension GitPont {
    func commitFile(
        _ reference: GitFileReference,
        content: Data,
        message: String,
        expectedVersion: GitRemoteVersion?
    ) async throws -> GitCommitResult
}

The facade resolves the provider and connection for each call, refreshes credentials when needed, and delegates to the provider implementation. Apps never talk to providers directly unless they choose to.

GitPontCore must not depend on the Git CLI module. GitPontGitCLI adds Git CLI support as a Swift extension:

public extension GitPont {
    func gitCredentialContext(
        forRemoteURL url: URL,
        preferredConnectionID: String? = nil
    ) async throws -> GitCLICredentialContext
}

GitCLICredentialContext is declared in GitPontGitCLI. Consumers that need this method import GitPontGitCLI; consumers like Lezin do not.

Provider Protocol

public protocol GitProvider: Sendable {
    var kind: GitProviderKind { get }
    var displayName: String { get }
    var capabilities: GitProviderCapabilities { get }
    var changeRequestTerm: GitChangeRequestTerm { get }

    func canHandle(url: URL) -> Bool
    func parse(url: URL) throws -> GitURLParseResult
    func account(instance: GitProviderInstance, credential: GitCredential) async throws -> GitAccount
    func repositories(context: GitProviderRequestContext) async throws -> GitList<GitRepository>
    func repository(_ reference: GitRepositoryReference, context: GitProviderRequestContext) async throws -> GitRepository
    func branches(repository: GitRepositoryReference, context: GitProviderRequestContext) async throws -> GitList<GitBranch>
    func readFile(_ reference: GitFileReference, context: GitProviderRequestContext) async throws -> GitRemoteFile
    func listDirectory(_ reference: GitFileReference, context: GitProviderRequestContext) async throws -> GitList<GitDirectoryEntry>
    func commitFile(_ change: GitFileChange, context: GitProviderRequestContext) async throws -> GitCommitResult
    func deleteFile(_ request: GitFileDeleteRequest, context: GitProviderRequestContext) async throws -> GitCommitResult
    func createBranch(_ request: GitCreateBranchRequest, context: GitProviderRequestContext) async throws -> GitBranch
    func deleteBranch(_ request: GitDeleteBranchRequest, context: GitProviderRequestContext) async throws
    func createRepository(_ request: GitCreateRepositoryRequest, context: GitProviderRequestContext) async throws -> GitRepository
    func forkRepository(_ reference: GitRepositoryReference, context: GitProviderRequestContext) async throws -> GitRepository
    func createPullRequest(_ request: GitPullRequestRequest, context: GitProviderRequestContext) async throws -> GitPullRequest
}

Provider request context carries the resolved connection metadata and credential for an operation:

public struct GitProviderRequestContext: Sendable {
    public var connection: GitConnection?
    public var credential: GitCredential?
}

Both fields are optional because public repositories may be readable without authentication. The facade must still prefer an authenticated context when one connection exists for the instance (unauthenticated GitHub requests are limited to 60/hour per IP). Write operations require both connection and credential; providers should throw .authenticationRequired if either is missing.

GitConnection remains metadata-only. Secrets are loaded from CredentialStore by the facade and passed to providers only in memory through GitProviderRequestContext.

Provider Construction

Provider modules expose concrete provider types with explicit instance lists. Known public hosts are registered by default; custom/self-hosted instances are registered by the consuming app.

public struct GitHubProvider: GitProvider {
    public init(httpClient: HTTPClient, oauth: OAuthAppConfig?)
}

public struct GitLabProvider: GitProvider {
    public init(httpClient: HTTPClient, instances: [GitProviderInstance], oauth: OAuthAppConfig?)
}

public struct ForgeProvider: GitProvider {
    public init(httpClient: HTTPClient, instances: [GitProviderInstance], oauth: OAuthAppConfig?)
}

Default app setup:

let providers: [GitProvider] = [
    GitHubProvider(httpClient: httpClient, oauth: githubOAuth),
    GitLabProvider(httpClient: httpClient, instances: [.gitLabCloud], oauth: gitLabOAuth),
    ForgeProvider(httpClient: httpClient, instances: [.codeberg], oauth: nil)
]

Self-hosted setup adds explicit instances:

let companyGitLab = GitProviderInstance.gitLabSelfHosted(
    baseURL: URL(string: "https://company.com/gitlab")!,
    displayName: "Company GitLab"
)

let providers: [GitProvider] = [
    GitLabProvider(httpClient: httpClient, instances: [.gitLabCloud, companyGitLab], oauth: gitLabOAuth)
]

canHandle(url:) must only return true for known public hosts or the provider's configured instances. It must never infer that an arbitrary host is GitLab, Forgejo, or Gitea.

Authentication Protocols

Authentication is split out from repository operations so PAT setup, OAuth start/complete, and token refresh are not invented by each app. Apps should call GitPont.startOAuth(_:) and GitPont.completeOAuth(_:) for provider-neutral OAuth orchestration, then pass the returned credential to addConnection(instance:credential:authMethod:).

public protocol GitAuthenticationProvider: Sendable {
    func authorizationHeaders(for credential: GitCredential, authMethod: GitAuthMethod) throws -> [String: String]
    func startOAuth(_ request: GitOAuthStartRequest) async throws -> GitOAuthStartResult
    func completeOAuth(_ request: GitOAuthCompletionRequest) async throws -> GitCredential
    func refreshCredential(_ credential: GitCredential, instance: GitProviderInstance) async throws -> GitCredential
}

Provider modules that support OAuth conform to GitAuthenticationProvider. PAT-only operation can still use addConnection(instance:credential:authMethod:) directly after the app collects a token.

Core OAuth models:

public struct GitOAuthStartRequest: Sendable {
    public var instance: GitProviderInstance
    public var method: GitAuthMethod
    public var appConfig: OAuthAppConfig
}

public enum GitOAuthStartResult: Sendable {
    case browser(GitOAuthBrowserSession)
    case device(GitOAuthDeviceSession)
}

public struct GitOAuthBrowserSession: Sendable {
    public var authorizationURL: URL
    public var state: String
    public var codeVerifier: String?
    public var redirectURI: URL
}

public struct GitOAuthDeviceSession: Sendable {
    public var verificationURI: URL
    public var userCode: String
    public var deviceCode: String
    public var interval: TimeInterval
    public var expiresAt: Date
}

public struct GitOAuthCompletionRequest: Sendable {
    public var instance: GitProviderInstance
    public var method: GitAuthMethod
    public var appConfig: OAuthAppConfig
    public var callbackURL: URL?
    public var state: String?
    public var codeVerifier: String?
    public var deviceCode: String?
}

The Swift library may add helper wrappers for opening browser sessions, listening for callback URLs, or polling device codes, but the provider-neutral core owns the models above.

Core Workflow

  1. App passes a URL to GitPont.
  2. GitPont asks registered providers which can handle it.
  3. Provider parses the URL into a GitURLParseResult.
  4. If the result is ambiguous (see below), GitPont resolves it against the branch list.
  5. GitPont resolves a matching connection if authentication is needed.
  6. Provider reads the file or repository metadata.
  7. App edits content.
  8. App asks GitPont to commit a file change or submit a change (branch/fork + PR).
  9. Provider sends the correct API request and returns a normalized result.

URL Parsing Is Two-Phase

Blob URLs cannot always be parsed purely syntactically. Branch names may contain slashes, so in

https://github.com/owner/repo/blob/feature/foo/doc.md

the ref may be feature (path foo/doc.md) or feature/foo (path doc.md). The same applies to GitLab and Forgejo/Gitea URLs.

Parsing therefore returns:

public enum GitURLParseResult: Sendable {
    case resolved(GitURLReference)
    case ambiguous(candidates: [GitURLReference])
}
  • parse(url:) is synchronous and never touches the network.
  • resolve(_:) disambiguates .ambiguous by listing branches through the API and selecting the longest branch name that matches a prefix of the ref+path remainder. If no candidate matches a real branch, throw .unsupportedURL.
  • openFile(from:) performs both phases internally; most apps only need openFile.
  • A single-segment ref is returned as .resolved (the common case); ambiguity only arises when the remainder after the ref marker has three or more segments.

Parsers must strip query strings and fragments (for example #L10 line anchors) before matching, and must accept commit-SHA permalinks (a 7–64 character hex ref is treated as resolved, never ambiguous).

Provider Registry

Provider resolution should be deterministic:

  1. Exact known platform hosts:
    • github.com (including raw.githubusercontent.com)
    • gitlab.com
    • codeberg.org
  2. Configured self-hosted instances.
  3. Custom URL parser fallback only when the app explicitly provides an instance config.

Avoid guessing that an arbitrary domain is GitLab or Forgejo. Require a configured connection for custom domains.

Connection Resolution

When an operation needs a connection for an instance:

  1. If the caller passed preferredConnectionID and it matches the instance, use it.
  2. If exactly one connection exists for the instance, use it.
  3. If multiple connections exist, throw .ambiguousConnection(instanceID:candidates:) so the app can present a picker.
  4. If none exists, throw .missingConnection (or proceed unauthenticated for reads where the capability allows it).

Capabilities

Providers should expose capabilities so apps can adapt UI:

public struct GitProviderCapabilities: OptionSet, Sendable {
    public static let publicFileRead
    public static let authenticatedFileRead
    public static let fileCommit
    public static let fileDelete
    public static let batchCommit
    public static let directoryList
    public static let branchCreate
    public static let repositoryCreate
    public static let repositoryFork
    public static let pullRequestCreate
    public static let gitCLICredentials
}

There is a single pullRequestCreate capability for all providers. The PR-vs-MR wording difference is a UI label, not a capability:

public enum GitChangeRequestTerm: String, Sendable, Codable {
    case pullRequest
    case mergeRequest
}

batchCommit (multiple files in one commit) is a declared capability but has no v1 API surface; do not build it until a consumer needs it.

Change Submission

Apps like Lezin need "save this edit as a direct commit, a branch + PR, or a fork + branch + PR" as one operation. Stitching those calls in every app duplicates partial-failure handling, so core owns it:

public struct GitChangeSubmission: Sendable {
    public enum Strategy: Sendable {
        case directCommit
        case branchAndPullRequest(branchName: String, title: String, body: String?, draft: Bool)
        case forkAndPullRequest(branchName: String, title: String, body: String?, draft: Bool)
        case automatic(branchName: String, title: String, body: String?, draft: Bool)
    }

    public var change: GitFileChange
    public var strategy: Strategy
}

public struct GitChangeResult: Sendable {
    public var commit: GitCommitResult
    public var pullRequest: GitPullRequest?
    public var usedRepository: GitRepositoryReference   // the fork when forking was used
    public var usedBranch: String
}

.automatic picks the cheapest strategy the user's permissions allow: direct commit if the user can push to the target branch, else branch + PR if the user can push to the repository, else fork + PR. Permission data comes from GitRepository.permissions (see Provider Model).

Fork flow details:

  • Reuse an existing fork when the provider reports one; otherwise create it.
  • After creating a fork, poll repository availability (forking is async on GitHub) with a bounded wait (10 attempts, 1s apart) before committing.
  • The PR is opened on the upstream repository with the fork branch as source (owner:branch head on GitHub, source_project_id on GitLab, owner:branch on Forgejo/Gitea).

Partial-failure rules:

  • If branch creation succeeds but the commit fails, surface the error via .partialSubmission naming the created branch so the app can inspect or clean up through deleteBranch.
  • If the commit succeeds but PR creation fails, return .partialSubmission carrying the successful GitChangeResult (without PR) so the app can retry PR creation without recommitting.

Pagination

GitHub, GitLab, Forgejo, and Gitea paginate all list endpoints. Providers must:

  • request the maximum page size (per_page=100),
  • follow pagination (GitHub/Forgejo/Gitea Link headers, GitLab x-next-page) until exhausted,
  • stop at a safety cap of 30 pages (3,000 items) and mark the result truncated.

List methods return complete, internally-paginated results. Apps never see page tokens in v1:

public struct GitList<Element: Sendable>: Sendable {
    public var items: [Element]
    public var truncated: Bool
}

Retry Policy

Core provides a default RetryPolicy applied by the facade:

  • Retry idempotent reads (GET) up to 3 times on HTTP 429 and 5xx, honoring Retry-After when present, otherwise exponential backoff (1s, 2s, 4s) with jitter.
  • Never automatically retry writes (commit, delete, branch, PR, fork). Map 429 on writes to .rateLimited(retryAfter:) and let the app decide.
  • The policy and its sleep function are injectable so tests run with zero delay.

Error Model

Errors must be normalized but preserve provider details:

public enum GitPontError: Error, Sendable {
    case unsupportedURL(String)
    case ambiguousURL(candidates: [GitURLReference])
    case missingConnection(GitProviderKind)
    case ambiguousConnection(instanceID: String, candidates: [GitConnection])
    case authenticationRequired
    case authenticationFailed(String)
    case permissionDenied(String)
    case notFound(String)
    case conflict(GitConflict)
    case fileTooLarge(size: Int?, limit: Int)
    case rateLimited(retryAfter: TimeInterval?)
    case providerUnavailable(String)
    case unsupportedCapability(String)
    case invalidProviderResponse(String)
    case partialSubmission(completed: GitChangeResult, failure: String)
}

Error messages must never contain token values (see Security).

Version Identity

Each loaded file must carry a provider-specific version identity. This is required to prevent overwriting remote changes.

public enum GitRemoteVersion: Hashable, Sendable, Codable {
    case blobSHA(String)      // GitHub, Forgejo, Gitea file/blob SHA
    case commitID(String)     // GitLab last_commit_id, or commit-level identity
    case opaque(provider: GitProviderKind, value: String)
}

When committing a file, include the known version where the provider supports it. If the provider reports a conflict, return .conflict with:

public struct GitConflict: Hashable, Sendable {
    public var reference: GitFileReference
    public var expectedVersion: GitRemoteVersion?
    public var remoteVersion: GitRemoteVersion?   // when the provider reports it
    public var providerMessage: String?
}

GitConflict intentionally does not carry remote content; apps that want a diff should call readFile after receiving the conflict.

HTTP Abstraction

Core defines the transport types so all providers are testable without the network:

public struct HTTPRequest: Sendable {
    public var method: String
    public var url: URL
    public var headers: [String: String]
    public var body: Data?
}

public struct HTTPResponse: Sendable {
    public var statusCode: Int
    public var headers: [String: String]
    public var body: Data
}

public protocol HTTPClient: Sendable {
    func send(_ request: HTTPRequest) async throws -> HTTPResponse
}

GitPontCore includes URLSessionHTTPClient for app and opt-in live integration use. Unit tests should continue to inject mock clients so default validation never depends on network access.

Header lookup on HTTPResponse must be case-insensitive.

Provider Model

The provider model separates provider kind, instance, account, connection, repository, and file reference. All types referenced by the Architecture facade and provider protocol are defined here.

These Swift definitions are canonical for the v1 Swift library. Future Android, web, or backend libraries should mirror the same fields and behavior with idiomatic platform types. Codable/raw-value choices should remain compatible with JSON-like persistence and cross-platform fixtures.

Provider Kind

public enum GitProviderKind: String, Hashable, Sendable, Codable {
    case github
    case gitLabCloud
    case gitLabSelfHosted
    case forgejo
    case gitea
    case bitbucketCloud
}

There is no codeberg kind. Codeberg is a preset forgejo instance (see below). Raw string values are the Codable representation; never rely on enum case ordering.

Instance

public struct GitProviderInstance: Hashable, Sendable, Codable {
    public var id: String
    public var kind: GitProviderKind
    public var baseURL: URL
    public var apiBaseURL: URL
    public var displayName: String
}

Core ships preset factories so hosts are defined in exactly one place:

public extension GitProviderInstance {
    static let github: GitProviderInstance      // https://github.com / https://api.github.com
    static let gitLabCloud: GitProviderInstance // https://gitlab.com / https://gitlab.com/api/v4
    static let codeberg: GitProviderInstance    // kind .forgejo, https://codeberg.org / https://codeberg.org/api/v1
    static let bitbucketCloud: GitProviderInstance // https://bitbucket.org / https://api.bitbucket.org/2.0

    static func gitLabSelfHosted(baseURL: URL, displayName: String?) -> GitProviderInstance
    static func forgejo(baseURL: URL, displayName: String?) -> GitProviderInstance
    static func gitea(baseURL: URL, displayName: String?) -> GitProviderInstance
}

Examples:

GitHub:
baseURL    https://github.com
apiBaseURL https://api.github.com

GitLab.com:
baseURL    https://gitlab.com
apiBaseURL https://gitlab.com/api/v4

Self-hosted GitLab:
baseURL    https://gitlab.company.com
apiBaseURL https://gitlab.company.com/api/v4

Codeberg (preset Forgejo):
baseURL    https://codeberg.org
apiBaseURL https://codeberg.org/api/v1

Forgejo/Gitea:
baseURL    https://git.example.com
apiBaseURL https://git.example.com/api/v1

Self-hosted factories must accept a baseURL that includes a path prefix (for example https://company.com/gitlab) and derive apiBaseURL from it.

Account

public struct GitAccount: Hashable, Sendable, Codable {
    public var id: String          // provider account ID, stringified
    public var login: String
    public var displayName: String?
    public var avatarURL: URL?
    public var email: String?      // only when the provider returns it
}

Connection

public struct GitConnection: Identifiable, Hashable, Sendable, Codable {
    public var id: String
    public var instance: GitProviderInstance
    public var accountID: String
    public var accountLogin: String
    public var displayName: String?
    public var authMethod: GitAuthMethod
    public var createdAt: Date
    public var updatedAt: Date
}

The connection stores metadata only. Secrets live in CredentialStore. Connection persistence is owned by ConnectionStore (see Authentication).

Repository Reference

public struct GitRepositoryReference: Hashable, Sendable, Codable {
    public var instance: GitProviderInstance
    public var namespace: String
    public var name: String
    public var defaultBranch: String?
    public var webURL: URL?
    public var cloneHTTPSURL: URL?
}

namespace may contain slashes for GitLab and Forgejo organization nesting.

Repository

GitRepository is the full metadata form returned by list/get/create/fork operations. It embeds a reference plus permission data needed by submitChange:

public struct GitRepository: Hashable, Sendable, Codable {
    public var reference: GitRepositoryReference
    public var description: String?
    public var isPrivate: Bool
    public var isFork: Bool
    public var parent: GitRepositoryReference?       // upstream when this is a fork
    public var permissions: GitRepositoryPermissions
    public var updatedAt: Date?
}

public struct GitRepositoryPermissions: Hashable, Sendable, Codable {
    public var canRead: Bool
    public var canPush: Bool
    public var canAdmin: Bool
}

Permission mapping:

  • GitHub: permissions.pull/push/admin on the repository object.
  • GitLab: permissions.project_access.access_level (push requires Developer, level ≥ 30).
  • Forgejo/Gitea: permissions.pull/push/admin.

When the provider omits permissions (unauthenticated read), default to canRead: true, canPush: false, canAdmin: false.

Branch

public struct GitBranch: Hashable, Sendable, Codable {
    public var name: String
    public var commitSHA: String
    public var isDefault: Bool
    public var isProtected: Bool
}

File Reference

public struct GitFileReference: Hashable, Sendable, Codable {
    public var repository: GitRepositoryReference
    public var path: String
    public var ref: String
    public var webURL: URL?
}

ref may be a branch, tag, or commit. For writes, it should usually be a branch.

URL Reference

The result of parsing a provider URL:

public struct GitURLReference: Hashable, Sendable {
    public var instance: GitProviderInstance
    public var namespace: String
    public var name: String
    public var ref: String?      // nil for bare repository URLs
    public var path: String?     // nil for repository or ref-only URLs
}

Loaded File

public struct GitRemoteFile: Hashable, Sendable {
    public var reference: GitFileReference
    public var content: Data
    public var encoding: GitFileEncoding
    public var version: GitRemoteVersion?
    public var size: Int?
    public var lastCommitID: String?
    public var etag: String?
}

public enum GitFileEncoding: String, Hashable, Sendable, Codable {
    case utf8      // provider returned text; content is the UTF-8 bytes
    case binary    // provider returned raw or Base64-decoded binary data
}

Providers decode Base64 transport encoding before returning; content is always the real file bytes. encoding records whether the provider identified the content as text. Apps must not assume utf8 and should check before rendering as a string.

Directory Entry

public struct GitDirectoryEntry: Hashable, Sendable, Codable {
    public enum EntryType: String, Sendable, Codable {
        case file
        case directory
        case symlink
        case submodule
    }

    public var name: String
    public var path: String
    public var type: EntryType
    public var size: Int?
}

File Change

public struct GitFileChange: Sendable {
    public var reference: GitFileReference
    public var content: Data
    public var message: String
    public var targetBranch: String
    public var baseBranch: String?          // create targetBranch from this branch when it does not exist (GitLab start_branch semantics)
    public var expectedVersion: GitRemoteVersion?
    public var allowBlindOverwrite: Bool    // default false; see Security
    public var authorName: String?
    public var authorEmail: String?
}

A GitFileChange with no expectedVersion creates the file if it does not exist. Updating an existing file without expectedVersion requires allowBlindOverwrite = true, otherwise the provider must throw .conflict.

File Delete

public struct GitFileDeleteRequest: Sendable {
    public var reference: GitFileReference
    public var message: String
    public var targetBranch: String
    public var expectedVersion: GitRemoteVersion?   // required unless allowBlindOverwrite
    public var allowBlindOverwrite: Bool            // default false
    public var authorName: String?
    public var authorEmail: String?
}

Deletion is an explicit API. Committing empty content must never be treated as a delete.

Commit Result

public struct GitCommitResult: Hashable, Sendable, Codable {
    public var commitSHA: String
    public var branch: String
    public var newVersion: GitRemoteVersion?   // new file version for subsequent edits, nil after delete
    public var webURL: URL?
}

newVersion lets an editor keep saving without re-reading the file: GitHub returns the new blob SHA in the commit response; GitLab requires a follow-up HEAD/GET of the file to learn the new last_commit_id (providers must do this internally so newVersion is populated); Forgejo/Gitea return the new file SHA.

Branch Creation

public struct GitCreateBranchRequest: Sendable {
    public var repository: GitRepositoryReference
    public var name: String
    public var fromRef: String   // branch name or commit SHA
}

Branch Deletion

public struct GitDeleteBranchRequest: Sendable {
    public var repository: GitRepositoryReference
    public var name: String
}

Repository Creation

public struct GitCreateRepositoryRequest: Sendable {
    public var name: String
    public var namespace: String?        // nil = user's personal namespace
    public var description: String?
    public var isPrivate: Bool
    public var initializeWithReadme: Bool
}

Pull Request

Use one type for GitHub pull requests, GitLab merge requests, and Forgejo/Gitea pull requests. UI wording comes from GitProvider.changeRequestTerm.

public struct GitPullRequestRequest: Sendable {
    public var repository: GitRepositoryReference    // the repository the PR is opened on (upstream when forking)
    public var title: String
    public var body: String?
    public var sourceBranch: String
    public var sourceRepository: GitRepositoryReference?   // set when the source branch lives in a fork
    public var targetBranch: String
    public var draft: Bool
}

The normalized result:

public struct GitPullRequest: Hashable, Sendable, Codable {
    public var id: String
    public var number: Int?
    public var title: String
    public var webURL: URL
    public var sourceBranch: String
    public var targetBranch: String
    public var providerName: String
}

Authentication

Authentication must be provider-neutral from the app perspective and provider-specific internally.

Auth Methods

public enum GitAuthMethod: String, Hashable, Sendable, Codable {
    case oauthDevice
    case oauthPKCE
    case personalAccessToken
}

Do not include SSH in v1. Sandboxed macOS apps make SSH key access, ssh-agent, known hosts, and prompts unreliable. GitFolder may keep its own advanced SSH mode outside git-pont, but the main GitPont path should be platform account connections.

Credential Store

Core should define a protocol only:

public protocol CredentialStore: Sendable {
    func save(_ credential: GitCredential, for connectionID: String) async throws
    func loadCredential(for connectionID: String) async throws -> GitCredential?
    func deleteCredential(for connectionID: String) async throws
}

Apple implementation should live in GitPontKeychain.

Secrets must not be encoded into app config JSON.

Connection Store

Core also defines where connection metadata lives, so Lezin and GitFolder do not each invent their own persistence:

public protocol ConnectionStore: Sendable {
    func save(_ connection: GitConnection) async throws
    func connections() async throws -> [GitConnection]
    func connection(id: String) async throws -> GitConnection?
    func delete(id: String) async throws
}

Core ships two implementations:

  • InMemoryConnectionStore (actor) for tests.
  • FileConnectionStore writing JSON to an app-provided directory URL. Connections contain no secrets, so plain JSON is acceptable.

Removing a connection through the facade (removeConnection(id:)) deletes both the connection metadata and its credential.

Credential Model

public struct GitCredential: Hashable, Sendable {
    public var accessToken: String
    public var refreshToken: String?
    public var tokenType: String?
    public var expiresAt: Date?
    public var scopes: [String]
}

GitHub

Preferred auth:

  • OAuth device flow for native apps.
  • PAT fallback for advanced/manual setup.

Required scopes depend on feature:

  • Repository read/write and PR creation need repo-level access (repo scope for classic tokens).
  • Fork creation also works with repo scope.
  • Fine-grained token support should be documented in UI, but core should accept opaque tokens.
  • Organizations enforcing SAML SSO may reject otherwise-valid classic tokens; surface the provider message via .permissionDenied so users understand they must authorize the token for the org.

GitLab.com and Self-Hosted GitLab

Preferred auth:

  • OAuth with PKCE when an OAuth application is configured.
  • PAT fallback.

Important scope detail:

  • Repository Files API write operations require api scope.
  • read_repository can read.
  • write_repository is for Git-over-HTTP and should not be treated as enough for REST API file writes.

Token lifetime detail (load-bearing, not optional):

  • GitLab OAuth access tokens expire after about 2 hours and always come with a refresh token. Any GitLab OAuth integration without working refresh will break within a session.
  • GitLab PATs do not auto-expire (unless configured) and need no refresh.

Self-hosted GitLab connection requires:

  • base URL
  • auth method
  • optional custom display name

Forgejo, Gitea, Codeberg

Supported auth:

  • Token auth.
  • OAuth where available and configured on the instance.

Forgejo and Gitea instance variance is expected. Apps should allow PAT/token setup as the reliable path.

Forgejo accepts:

  • Authorization: Bearer ...
  • Authorization: token ...

Provider implementation should use Authorization: token ... for PATs and Authorization: Bearer ... for OAuth tokens, covered by tests.

Connection Flow

PAT/token connection flow:

  1. User chooses provider.
  2. App creates or selects GitProviderInstance.
  3. User pastes token.
  4. Provider validates token by loading current account.
  5. Facade saves connection metadata through ConnectionStore.
  6. Facade saves the secret through CredentialStore.

Steps 5 and 6 are wrapped by GitPont.addConnection(instance:credential:authMethod:).

OAuth connection flow:

  1. User chooses provider.
  2. App creates or selects GitProviderInstance.
  3. App calls GitPont.startOAuth(_:).
  4. For .browser, the app opens authorizationURL and later passes the callback URL to completeOAuth(_:).
  5. For .device, the app shows verificationURI and userCode, then polls by calling GitPont.completeOAuth(_:) with the deviceCode at the returned interval until completion or expiry.
  6. Provider returns GitCredential.
  7. App calls GitPont.addConnection(instance:credential:authMethod:).

Core defines the provider-neutral OAuth request/session models in Architecture → Authentication Protocols. Provider modules still implement GitAuthenticationProvider; apps normally use the GitPont facade.

OAuth App Ownership

Core supports injected OAuth configuration only:

public struct OAuthAppConfig: Sendable {
    public var clientID: String
    public var clientSecret: String?   // avoid in native apps; only for backend-mediated flows
    public var redirectURI: URL?
    public var scopes: [String]
}

GitPont ships no default client IDs. Each consuming app registers its own OAuth apps and passes the config. PAT/token auth works without any OAuth app setup and is the guaranteed path for every provider.

Token Refresh

If a credential has a refreshToken and expiresAt, the facade refreshes automatically before requests when the token is within 60 seconds of expiry. If a provider still reports authentication failure, the facade performs one forced refresh and retries the operation once. PAT credentials without refreshToken are used as-is.

Refresh is delegated to the matching provider through GitAuthenticationProvider.refreshCredential(_:instance:). Providers that do not support refresh return .unsupportedCapability only when asked to refresh a credential that claims to be refreshable.

Refresh must be race-free:

  • Refresh is serialized per connection through an actor. Concurrent requests against an expiring connection await one shared refresh; there must never be two in-flight refreshes for the same connection (GitLab rotates refresh tokens, so a duplicate refresh invalidates the winner).
  • The refreshed credential is saved to CredentialStore before the refresh actor releases waiting requests.

If refresh fails:

  • return .authenticationFailed
  • do not delete the existing connection automatically
  • let the app offer reconnect

Provider-specific refresh endpoints:

  • GitLab OAuth: call the instance OAuth token endpoint with grant_type=refresh_token; this is required because GitLab rotates refresh tokens.
  • GitHub device flow: refresh only when the returned credential includes a refresh token and expiry; otherwise treat the token as non-refreshing.
  • Forgejo/Gitea OAuth: refresh only for instances configured with OAuth metadata; PAT/token auth remains the reliable fallback.

Multiple Accounts

Multiple connections may exist for the same instance (for example a personal and a work GitHub account). Resolution order is defined in Architecture → Connection Resolution: explicit preferredConnectionID wins, a single match is used automatically, multiple matches throw .ambiguousConnection so the app can ask the user. Apps that persist a per-document or per-folder connectionID avoid the picker entirely.

Provider APIs

This document describes the provider-specific API mapping for every operation in the GitProvider protocol. The implementation agent should verify endpoint details against current official docs before coding.

Pagination

All list endpoints below are paginated. Providers must request per_page=100 (GitLab: per_page=100), follow pagination until exhausted, and stop at the safety cap defined in Architecture → Pagination.

  • GitHub, Forgejo, Gitea: follow the Link: <...>; rel="next" response header.
  • GitLab: follow the x-next-page response header (empty when done).

GitHub

Base:

Web: https://github.com
API: https://api.github.com

Account (connection validation):

GET /user

List repositories:

GET /user/repos?per_page=100&sort=updated

Repository metadata (includes permissions, default_branch, parent for forks):

GET /repos/{owner}/{repo}

List branches:

GET /repos/{owner}/{repo}/branches?per_page=100

Read file:

GET /repos/{owner}/{repo}/contents/{path}?ref={ref}

Size limit: the contents API returns Base64 content up to 1 MB. Between 1 MB and 100 MB it returns content: "" with encoding: "none"; the provider must then fetch the blob via

GET /repos/{owner}/{repo}/git/blobs/{file_sha}

Above 100 MB, throw .fileTooLarge(size:limit:).

List directory: same contents endpoint with a directory path returns a JSON array of entries.

Commit file (create or update):

PUT /repos/{owner}/{repo}/contents/{path}

Payload includes:

  • message
  • content as Base64
  • sha for updates/conflict protection (from expectedVersion .blobSHA)
  • branch
  • optional committer/author name and email

A 409/422 referencing SHA mismatch maps to .conflict. The response contains the new blob sha for GitCommitResult.newVersion.

Delete file:

DELETE /repos/{owner}/{repo}/contents/{path}

Payload includes message, sha, branch.

Create branch:

  1. Load base ref: GET /repos/{owner}/{repo}/git/ref/heads/{branch} (or use a commit SHA directly)
  2. Create ref: POST /repos/{owner}/{repo}/git/refs with ref: "refs/heads/{name}", sha

Delete branch:

DELETE /repos/{owner}/{repo}/git/ref/heads/{branch}

Create repository:

POST /user/repos                (personal namespace)
POST /orgs/{org}/repos          (organization namespace)

Fork repository:

POST /repos/{owner}/{repo}/forks

Forking is asynchronous: the response returns immediately but the fork may not be ready. Poll GET /repos/{forkOwner}/{repo} until it succeeds (bounded, see Architecture). Detect an existing fork by checking the authenticated user's repo of the same name whose parent matches.

Create pull request:

POST /repos/{owner}/{repo}/pulls

For same-repo PRs, head is the branch name. For fork PRs, head is {forkOwner}:{branch}. draft: true for draft PRs.

GitLab.com and Self-Hosted GitLab

Base:

GitLab.com API: https://gitlab.com/api/v4
Self-hosted:    {baseURL}/api/v4

Project ID can be the numeric ID or URL-encoded namespace path. Prefer URL-encoded namespace path when parsing from URLs.

Account:

GET /user

List repositories (projects the user is a member of):

GET /projects?membership=true&per_page=100&order_by=last_activity_at

Project metadata (includes default_branch, permissions, forked_from_project):

GET /projects/:id

List branches:

GET /projects/:id/repository/branches?per_page=100

Read file:

GET /projects/:id/repository/files/:file_path?ref=:ref

File response includes:

  • content Base64 encoded
  • last_commit_id
  • blob_id
  • content_sha256
  • size

No hard 1 MB limit like GitHub, but responses are memory-bound; enforce the same app-side limit and throw .fileTooLarge above 100 MB.

List directory:

GET /projects/:id/repository/tree?path=:path&ref=:ref&per_page=100

Update file:

PUT /projects/:id/repository/files/:file_path

Payload includes:

  • branch
  • commit_message
  • content
  • encoding optionally base64
  • last_commit_id for conflict protection (from expectedVersion .commitID)
  • start_branch for branch creation from another branch (GitFileChange.baseBranch)
  • optional author_name / author_email

A 400 with "You are attempting to update a file that has changed since you started editing it" maps to .conflict. The update response does not return the new last_commit_id; the provider must re-read the file metadata (GET .../files/:file_path?ref=:branch or HEAD with x-gitlab-last-commit-id header) to populate GitCommitResult.newVersion.

Create file:

POST /projects/:id/repository/files/:file_path

Delete file:

DELETE /projects/:id/repository/files/:file_path

Payload includes branch, commit_message, and last_commit_id for conflict protection.

Create branch:

POST /projects/:id/repository/branches?branch={name}&ref={fromRef}

Delete branch:

DELETE /projects/:id/repository/branches/{branch}

Create repository:

POST /projects            (name, visibility, initialize_with_readme, namespace_id optional)

Fork repository:

POST /projects/:id/fork

Fork may be processed asynchronously (import_status); poll GET /projects/:forkID until import_status is finished or absent.

Create merge request:

POST /projects/:id/merge_requests

For fork MRs, call this on the fork project with target_project_id set to the upstream project ID. Draft MRs are expressed by prefixing the title with Draft: .

Forgejo, Gitea, Codeberg

Base:

API: {baseURL}/api/v1
Codeberg API: https://codeberg.org/api/v1

Codeberg is a preset Forgejo instance; there is no separate Codeberg code path.

Forgejo and Gitea expose OpenAPI at:

{baseURL}/swagger.v1.json

Account:

GET /user

List repositories:

GET /user/repos?limit=50&page={n}

Repository metadata (includes permissions, default_branch, parent):

GET /repos/{owner}/{repo}

List branches:

GET /repos/{owner}/{repo}/branches

File and directory operations use the GitHub-like contents API:

GET    /repos/{owner}/{repo}/contents/{filepath}?ref={ref}
PUT    /repos/{owner}/{repo}/contents/{filepath}     (update; requires "sha")
POST   /repos/{owner}/{repo}/contents/{filepath}     (create)
DELETE /repos/{owner}/{repo}/contents/{filepath}     (requires "sha")

For updates and deletes the file sha is required by the API — conflict protection is mandatory here, which matches the GitPont default. A SHA mismatch maps to .conflict.

Create branch:

POST /repos/{owner}/{repo}/branches     (new_branch_name, old_ref_name)

Delete branch:

DELETE /repos/{owner}/{repo}/branches/{branch}

Create repository:

POST /user/repos
POST /orgs/{org}/repos

Fork repository:

POST /repos/{owner}/{repo}/forks

Create pull request:

POST /repos/{owner}/{repo}/pulls

Fork PRs use head: "{forkOwner}:{branch}" like GitHub.

Because self-hosted instances can run different versions, write integration tests against fixtures and keep provider errors explicit when an endpoint is missing (map 404/405 on a known-path write endpoint to .unsupportedCapability with the instance version in the message when available).

URL Parsing

Support these URL shapes.

GitHub:

https://github.com/owner/repo/blob/main/path/file.md
https://github.com/owner/repo/blob/{40-char-sha}/path/file.md      (permalink)
https://raw.githubusercontent.com/owner/repo/main/path/file.md
https://raw.githubusercontent.com/owner/repo/refs/heads/main/path/file.md
https://github.com/owner/repo/tree/main/path                       (directory)
https://github.com/owner/repo

GitLab:

https://gitlab.com/group/project/-/blob/main/path/file.md
https://gitlab.com/group/project/-/raw/main/path/file.md
https://gitlab.company.com/group/subgroup/project/-/blob/main/path/file.md
https://gitlab.com/group/project/-/tree/main/path                  (directory)
https://gitlab.com/group/project

Forgejo/Gitea/Codeberg:

https://codeberg.org/owner/repo/src/branch/main/path/file.md
https://codeberg.org/owner/repo/src/commit/{sha}/path/file.md
https://codeberg.org/owner/repo/raw/branch/main/path/file.md
https://git.example.com/owner/repo/src/branch/main/path/file.md
https://git.example.com/owner/repo

The parser must preserve:

  • provider instance
  • namespace
  • repo
  • ref
  • path

Parsing rules:

  • Strip query strings and fragments (?plain=1, #L10-L20) before matching.
  • A ref that is a 7–64 character hex string is a commit permalink and is never ambiguous.
  • Branch names may contain slashes; when the segment after the ref marker plus remaining path has more than one possible split, return .ambiguous with all candidate (ref, path) splits, longest-ref first. See Architecture → URL Parsing Is Two-Phase.
  • Forgejo/Gitea src/branch/ URLs mark the ref boundary explicitly but the ref itself may still contain slashes; the same candidate logic applies. src/commit/{sha} is always resolved.
  • Trailing .git on repository URLs must be stripped.

Do not guess custom-host provider type unless the instance is configured.