Skip to content

Authenticate to a forge

NewRepo resolves credentials once, at construction, from Settings. It never reads configuration files, environment variables, or a keychain itself — you resolve those and pass the results in. See why git needs no forge for the reasoning.

SSH takes priority

The decision is made in one place:

flowchart TD
    A[NewRepo] --> B{SSH.Configured?}
    B -- yes --> C[SSH auth<br/>Token is never called]
    B -- no --> D{AuthEnabled<br/>or Token != nil?}
    D -- yes --> E[Token auth]
    D -- no --> F[Unauthenticated]

A repository configured for SSH never invokes Token. That is a deliberate guarantee, not an accident of ordering: a token source may hit the OS keychain and prompt the user to unlock it, and an SSH-authenticated clone must never trigger that prompt.

Token authentication

r, err := repo.NewRepo(repo.Settings{
    Forge:   repo.ForgeGitLab,
    Private: true,
    Token:   repo.StaticToken(os.Getenv("GITLAB_TOKEN")),
    FS:      afero.NewOsFs(),
})

Token is a func() string, not a string, so resolution is lazy — it runs only on the code path that actually authenticates. StaticToken adapts a token you already hold; supply your own closure when resolution is expensive:

Token: func() string { return keychain.Get("gitlab") },

The forge selects the username git-over-HTTPS expects. You never set it yourself:

Forge Username sent
ForgeGitLab oauth2
ForgeBitbucket x-token-auth
everything else (incl. GitHub, Gitea, Codeberg) x-access-token

An unrecognised Forge string is accepted and treated as GitHub — the constants are plain strings so you can name a forge this package has never heard of. ForgeDirect and "" also fall back to GitHub's convention, since a direct download source has no git remote and so no convention of its own.

Private repositories fail fast

Private: true turns a missing token into an immediate, hinted error rather than an unauthenticated clone attempt that fails later with an opaque git message:

no GITLAB token available for private repository
HINT: Set GITLAB_TOKEN or configure gitlab.auth.env in your config to enable git operations

Leave Private false for a public repository and a missing token simply logs at debug and proceeds unauthenticated.

AuthEnabled: true requests token auth even when Token is nil, so a missing credential is reported rather than silently skipped.

SSH authentication

r, err := repo.NewRepo(repo.Settings{
    Forge: repo.ForgeGitHub,
    SSH: repo.SSHSettings{
        Configured: true,
        HasKey:     true,
        Path:       "/home/me/.ssh/id_ed25519",
    },
    FS: afero.NewOsFs(),
})

The two flags encode a distinction the caller's configuration format cannot express in a single field:

  • Configured — an SSH block exists for this forge at all. This is what selects the SSH path.
  • HasKey — that block is structured and names a key, rather than being a bare scalar such as github.ssh: true.

Configured: true, HasKey: false is a real, supported case: the user asked for SSH but gave no key, so the package falls back to ssh-agent and warns. It also falls back to ssh-agent when Type is "agent", or when Path is empty.

Keys are read through Settings.FS, so a test can supply a MemMapFs.

Path is resolved by you, not by this package

Path is a resolved filesystem path, never an environment-variable name. This package reads no environment of its own — every input arrives through Settings, so construction is fully determined by what you pass and nothing changes underneath it because of ambient process state.

Most configuration formats let a user name an environment variable instead of a path. Apply that precedence at your call site with the KeyPath helper:

// explicit path wins; otherwise read the named variable; "" if neither resolves
settings.SSH.Path = repo.KeyPath(cfg.SSHKeyPath, "GITHUB_SSH_KEY")

KeyPath is a helper for callers — this package never calls it. Resolution stays in your composition root, alongside the rest of your configuration.

For an encrypted key, read it yourself and set it explicitly:

key, err := repo.GetSSHKeyWithPassphrase(path, fs, passphrase)
if err != nil {
    return err
}

r.SetKey(key)

Diagnostics

Settings.Logger is a two-method seam (Debug, Warn) that a *slog.Logger satisfies directly — no logging library is imposed on you:

Logger: slog.Default(),

A nil Logger is tolerated; diagnostics are discarded. It is worth wiring up, because the fallback decisions above (agent fallback, unauthenticated access) are reported only through it.

Settings.Progress is a different stream, and worth wiring up for more than debugging:

Progress: os.Stderr,

It receives the remote server's sideband output during clone, pull and push — what real git renders as Receiving objects: 45% (450/1000) and remote: ... lines. That is not something Logger can reproduce: Logger reports decisions this package made, while Progress carries messages the server sent, including pre-receive hook rejection reasons and the "To create a merge request, visit …" URLs. Leave it nil and a push rejected by a server-side hook surfaces as an error with the reason stripped out.

Push backfills it from Settings the same way it backfills Auth, so setting other push options does not lose it; an explicit opts.Progress still wins.

Like Path, it is a constructor argument rather than an environment-variable switch: it is scoped to one repository instead of the whole process, and a test can capture it into a bytes.Buffer without mutating global state.

It is remote-controlled text

The bytes come from the server and may contain terminal control characters. Send it to a terminal or a buffer; don't pipe it unsanitised into a log aggregator.