Git CLI Credentials

GitPontGitCLI exists for apps like GitFolder that shell out to system git.

It should not perform sync operations. It only resolves provider credentials into a safe, non-interactive Git credential context.

Credential Context

GitPontGitCLI owns this type and adds the gitCredentialContext method as an extension on GitPont. GitPontCore must not import or depend on GitPontGitCLI.

public struct GitCLICredentialContext: Hashable, Sendable {
    public var argumentsPrefix: [String]        // e.g. ["-c", "credential.helper=..."]
    public var environment: [String: String]
}

This is the only shape. There is no separate helperConfig accessor; argumentsPrefix is prepended to the git argument list as-is.

Usage:

let context = try await gitPont.gitCredentialContext(
    forRemoteURL: remoteURL,
    preferredConnectionID: folder.connectionID
)
let result = try gitRunner.run(
    context.argumentsPrefix + ["ls-remote", "--heads", remoteURL.absoluteString],
    environment: context.environment
)

HTTPS Token Auth

For HTTPS remotes, prefer an inline credential helper with an environment variable, not embedding tokens in remote URLs.

Example shape:

git -c credential.helper='!f() { printf "username=%s\npassword=%s\n" "$GITPONT_USERNAME" "$GITPONT_TOKEN"; }; f' ...

Environment:

GITPONT_USERNAME=...
GITPONT_TOKEN=...
GIT_TERMINAL_PROMPT=0

The token value appears only in environment, never in argumentsPrefix. The helper script text in argumentsPrefix references the variables by name.

If the credential for the connection is an expiring OAuth token, the facade refreshes it before building the context (see Authentication → Token Refresh). Contexts are single-use: request a fresh context per sync run rather than caching one.

Provider username defaults:

  • GitHub OAuth/PAT: x-access-token
  • GitLab OAuth: oauth2; GitLab PAT: the account login (or any non-empty username; GitLab accepts the PAT as password)
  • Forgejo/Gitea/Codeberg: account login when known, otherwise token-compatible placeholder

Provider implementations should own these details.

Remote URL Matching

Given a remote URL:

https://github.com/owner/repo.git
https://gitlab.com/group/project.git
https://gitlab.company.com/group/project.git
https://codeberg.org/owner/repo.git

GitPont should:

  1. Normalize the URL (strip .git, trailing slashes, credentials in the URL if present).
  2. Resolve the provider instance (known hosts first, then configured instances).
  3. Find a connection for the instance, honoring preferredConnectionID; multiple matches without a preference throw .ambiguousConnection.
  4. Load credentials, refreshing if needed.
  5. Return CLI context.

If no matching connection exists, throw .missingConnection.

No SSH in v1

Do not implement SSH credential handling in GitPont v1.

GitFolder can keep its existing advanced SSH path if desired, but provider-based account connections should be the default and documented route.

Testing Strategy

git-pont must be heavily tested because provider integrations break easily and app data loss would be unacceptable.

Test Layers

  1. Pure model tests
  2. URL parser tests (including ambiguity)
  3. Request construction tests
  4. Response decoding tests
  5. Pagination tests
  6. Error mapping tests
  7. Credential and connection store tests
  8. Token refresh tests
  9. Git CLI credential tests
  10. Provider contract tests with mocked HTTP
  11. Change submission (orchestration) tests
  12. Optional live integration tests gated by environment variables

Mocked HTTP

Core defines the injectable HTTPClient (see Architecture → HTTP Abstraction). Tests use a MockHTTPClient that matches requests by method + URL pattern and replays fixture responses, recording every request for assertion.

Tests should not hit the network by default.

Use fixture-driven tests. SwiftPM keeps resources per test target, so each provider target owns its provider fixture files:

libs/swift/Tests/GitPontGitHubTests/Fixtures/GitHub/read-file.json
libs/swift/Tests/GitPontGitHubTests/Fixtures/GitHub/list-directory.json
libs/swift/Tests/GitPontGitLabTests/Fixtures/GitLab/read-file.json
libs/swift/Tests/GitPontGitLabTests/Fixtures/GitLab/list-directory.json
libs/swift/Tests/GitPontForgeTests/Fixtures/Forgejo/read-file.json
libs/swift/Tests/GitPontForgeTests/Fixtures/Forgejo/list-directory.json

Add new provider response cases as JSON fixtures where the payload is reusable across future Android, web, or backend kits. Keep request-only assertions inline when there is no response payload worth sharing.

URL Parser Matrix

Test at least:

  • GitHub blob URL
  • GitHub blob URL with commit-SHA permalink
  • GitHub blob URL with #L10 fragment and query string (stripped)
  • GitHub raw URL (including refs/heads/ form)
  • GitHub tree (directory) URL
  • GitHub repo URL, with and without .git
  • GitHub blob URL with slashed branch → .ambiguous with correct candidate order
  • GitLab blob URL
  • GitLab raw URL (/-/raw/)
  • GitLab subgroup blob URL
  • GitLab self-hosted blob URL (configured instance)
  • GitLab repo URL
  • Codeberg src/branch/ URL
  • Codeberg src/commit/{sha} URL
  • Forgejo custom source URL
  • Gitea custom source URL
  • invalid URL
  • unsupported host
  • custom host without configured instance

Resolution tests: .ambiguous result + mocked branch list → correct (ref, path) chosen; no matching branch → .unsupportedURL.

Pagination Tests

  • Multi-page branch and repository lists are concatenated (GitHub Link header, GitLab x-next-page).
  • Safety cap sets truncated = true and stops requesting.
  • per_page=100 is sent on list requests.

Conflict Tests

Test that commits include provider version identity:

  • GitHub sends sha
  • GitLab sends last_commit_id
  • Forgejo/Gitea sends sha

Test provider conflict responses map to GitPontError.conflict(...) with a populated GitConflict.

Test that GitCommitResult.newVersion is populated after a successful commit for every provider (including GitLab's follow-up read).

Change Submission Tests

With mocked HTTP:

  • .directCommit performs one commit call.
  • .branchAndPullRequest creates branch, commits, opens PR; PR request uses the right head format.
  • .forkAndPullRequest creates or reuses the connected account's fork, confirms newly created forks, commits to the fork, and opens PR on upstream with fork head (owner:branch / target_project_id).
  • .automatic picks direct commit when permissions.canPush and the branch is not protected; branch+PR when push but protected/PR requested; fork+PR when canPush == false.
  • Commit success + PR failure → .partialSubmission carrying the commit result.
  • Branch success + commit failure → .partialSubmission naming the branch.

Credential and Connection Tests

Use in-memory stores in core tests:

final actor InMemoryCredentialStore: CredentialStore
final actor InMemoryConnectionStore: ConnectionStore

Keychain tests live in GitPontKeychainTests. The default suite verifies the credential payload round trip. A live Keychain save/load/update/delete test is opt-in with GITPONT_RUN_KEYCHAIN_TESTS=1.

Test:

  • save/load/delete for both stores
  • token not serialized in connection metadata (encode GitConnection, assert no token substring)
  • missing credential returns nil
  • removeConnection deletes both metadata and credential

Token Refresh Tests

  • Expiring credential triggers refresh before the request; refreshed token is used.
  • Concurrent requests against one expiring connection produce exactly one refresh call (assert via mock request count).
  • Refreshed credential is saved to the store before dependent requests run.
  • Git CLI credential context uses a refreshed token for expiring OAuth credentials.
  • Refresh failure does not delete the connection.
  • Unexpected provider authentication failure triggers one refresh-and-retry when a refresh token exists.

Git CLI Credential Tests

Test that:

  • tokens are placed in environment, not command arguments (assert token value absent from every argumentsPrefix element)
  • GIT_TERMINAL_PROMPT=0 is set
  • GitHub, GitLab, and Forgejo contexts use provider-appropriate usernames
  • unknown provider returns .unsupportedCapability
  • missing connection returns .missingConnection
  • multiple connections without preferredConnectionID returns .ambiguousConnection; with it, the preferred one is used

Live Tests

Live tests are disabled by default and require explicit environment variables. The checked-in live suite performs account smoke tests through URLSessionHTTPClient when a token is present:

GITPONT_LIVE_GITHUB_TOKEN
GITPONT_LIVE_GITLAB_TOKEN
GITPONT_LIVE_FORGEJO_TOKEN
GITPONT_LIVE_FORGEJO_BASE_URL   # optional, defaults to Codeberg

The checked-in live suite also includes opt-in disposable write cycles. These create a temporary branch, create/read/delete a file under .git-pont-live/, and delete the temporary branch. Configure them only with dedicated test repositories:

GITPONT_LIVE_GITHUB_WRITE_REPO          # owner/repo
GITPONT_LIVE_GITHUB_WRITE_BASE_REF      # optional, defaults to main
GITPONT_LIVE_GITLAB_WRITE_REPO          # namespace/project
GITPONT_LIVE_GITLAB_WRITE_BASE_REF      # optional, defaults to main
GITPONT_LIVE_FORGEJO_WRITE_REPO         # owner/repo
GITPONT_LIVE_FORGEJO_WRITE_BASE_REF     # optional, defaults to main

Never run live write tests in normal CI unless a dedicated test repo is configured.

Use npm run validate:live as the release gate for all configured live provider write cycles. It fails fast when any required token or disposable repository variable is missing.

Safety Tests

Add tests that prevent common data-loss behavior:

  • updating an existing file without expected version and without allowBlindOverwrite throws .conflict (no request sent)
  • deleting without expected version and without allowBlindOverwrite is rejected
  • path traversal segments (..) are rejected
  • absolute file paths are rejected as repository paths
  • NUL/control characters in paths are rejected
  • empty commit message is rejected
  • delete requires the explicit delete API; empty-content commit is not a delete
  • files above the provider size limit throw .fileTooLarge, not a decode error