Concepts
The conceptual layer defines the provider-neutral contract. These pages should win when another document disagrees about models, flows, or error behavior.
Contract
Platform Behavior
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, notString. - 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
- App passes a URL to
GitPont. GitPontasks registered providers which can handle it.- Provider parses the URL into a
GitURLParseResult. - If the result is ambiguous (see below),
GitPontresolves it against the branch list. GitPontresolves a matching connection if authentication is needed.- Provider reads the file or repository metadata.
- App edits content.
- App asks
GitPontto commit a file change or submit a change (branch/fork + PR). - 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.ambiguousby 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 needopenFile.- 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:
- Exact known platform hosts:
github.com(includingraw.githubusercontent.com)gitlab.comcodeberg.org
- Configured self-hosted instances.
- 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:
- If the caller passed
preferredConnectionIDand it matches the instance, use it. - If exactly one connection exists for the instance, use it.
- If multiple connections exist, throw
.ambiguousConnection(instanceID:candidates:)so the app can present a picker. - 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:branchhead on GitHub,source_project_idon GitLab,owner:branchon Forgejo/Gitea).
Partial-failure rules:
- If branch creation succeeds but the commit fails, surface the error via
.partialSubmissionnaming the created branch so the app can inspect or clean up throughdeleteBranch. - If the commit succeeds but PR creation fails, return
.partialSubmissioncarrying the successfulGitChangeResult(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
Linkheaders, GitLabx-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-Afterwhen 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/adminon 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.FileConnectionStorewriting 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 (
reposcope for classic tokens). - Fork creation also works with
reposcope. - 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
.permissionDeniedso 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
apiscope. read_repositorycan read.write_repositoryis 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:
- User chooses provider.
- App creates or selects
GitProviderInstance. - User pastes token.
- Provider validates token by loading current account.
- Facade saves connection metadata through
ConnectionStore. - Facade saves the secret through
CredentialStore.
Steps 5 and 6 are wrapped by GitPont.addConnection(instance:credential:authMethod:).
OAuth connection flow:
- User chooses provider.
- App creates or selects
GitProviderInstance. - App calls
GitPont.startOAuth(_:). - For
.browser, the app opensauthorizationURLand later passes the callback URL tocompleteOAuth(_:). - For
.device, the app showsverificationURIanduserCode, then polls by callingGitPont.completeOAuth(_:)with thedeviceCodeat the returned interval until completion or expiry. - Provider returns
GitCredential. - 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
CredentialStorebefore 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-pageresponse 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:
messagecontentas Base64shafor updates/conflict protection (fromexpectedVersion .blobSHA)branch- optional
committer/authorname 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:
- Load base ref:
GET /repos/{owner}/{repo}/git/ref/heads/{branch}(or use a commit SHA directly) - Create ref:
POST /repos/{owner}/{repo}/git/refswithref: "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:
contentBase64 encodedlast_commit_idblob_idcontent_sha256size
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:
branchcommit_messagecontentencodingoptionallybase64last_commit_idfor conflict protection (fromexpectedVersion .commitID)start_branchfor 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
.ambiguouswith 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
.giton repository URLs must be stripped.
Do not guess custom-host provider type unless the instance is configured.