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.

Private: true on its own does nothing

Private is only read on the token-authentication path, and that path only runs when AuthEnabled is true or Token is non-nil. repo.NewRepo(repo.Settings{Private: true}) returns no error and configures no credentials, so the fail-fast you asked for never happens. Set AuthEnabled: true alongside it, or pass a Token.

AuthEnabled: true requests token auth even when Token is nil, so a missing credential is reported rather than silently skipped. A StaticToken("") counts as supplying a Token (it is non-nil) and an empty result is treated as "no token available", so it triggers the same error.

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 exactly "agent" (the comparison is case-sensitive), or when Path is empty.

The agent fallback needs an agent. With SSH_AUTH_SOCK unset, NewRepo fails with failed to create SSH auth: error creating SSH agent: "SSH agent requested but SSH_AUTH_SOCK not-specified".

Keys are read through Settings.FS, so a test can supply a MemMapFs. Every failure mode of the key load (missing, a directory, malformed, encrypted) is listed under failed to get SSH key.

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, with one exception: the ssh-agent, which go-git finds through SSH_AUTH_SOCK, for agent mode and for the agent-first check below alike.

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, and this package never calls it. Resolution stays in your composition root, alongside the rest of your configuration.

A passphrase-protected key

Give SSH.Passphrase a source, and NewRepo opens an encrypted key in file mode:

// lookupPassphrase is yours: an OS keychain, a secrets manager, a prompt.
settings.SSH.Passphrase = func() string { return lookupPassphrase() }

Two things happen before the source is called, so it is called at most once and often not at all:

  1. The agent is asked first. If an ssh-agent holds the key at Path, the agent signs, offering that key alone, and the file is never decrypted. The key is recognised by its public half, from the .pub beside it, from the key itself when unencrypted, or from the clear public key an OpenSSH-format encrypted key carries. A PEM-encrypted key with no .pub has no public half to compare, so it skips this step.
  2. The key is tried without a passphrase. An unencrypted key opens here, and the source is never called.

Only an encrypted key the agent does not hold reaches the source. An empty return, or no source at all, gives the passphrase-protected error as before, and a wrong passphrase fails naming the key. A source backed by an OS keychain therefore prompts for an unlock only on a run that needs the passphrase.

GetSSHKeyWithPassphrase is still there for a caller that loads a key itself and sets it with SetKey.

Pin the forge's host keys

By default go-git checks a server's host key against SSH_KNOWN_HOSTS, or else ~/.ssh/known_hosts. Both belong to the environment. A bot that should trust exactly the forges it was configured for names its own file instead:

settings.SSH.KnownHosts = filepath.Join(configDir, "known_hosts")

Every SSH connection the Repo makes is then checked against that file and nothing else. A host missing from it is refused, and so is a host whose key differs. Nothing is ever added to the file, so there is no trust on first use.

Build the file once, out of band, and check it before you trust it:

ssh-keyscan gitlab.com github.com > known_hosts
ssh-keygen -lf known_hosts

Compare the fingerprints with the ones the forge publishes. ssh-keyscan asks the network, and the network is what you are pinning against.

The file may pin one key type per host, or several. go/repo picks the host key algorithms from the file for each host it dials, so a forge offering several types presents one the file holds.

A missing or unreadable file fails NewRepo with an error naming the path. Auth you pass in an operation's own options is used as you built it, with its own host key choice.

Three things to know when the file pins one key type per host:

  • An ~/.ssh/config alias is resolved first, as go-git resolves it before dialling. The file pins the Hostname and Port the alias names, not the alias.
  • Submodules dial with the parent's choice of algorithms. go-git reuses the clone's auth for every submodule, so a submodule on another host is checked against the file, but offered the parent host's key types. Pin the same key types for both hosts, or the submodule is refused.
  • GetAuth() returns the auth without the check. Dialling with it yourself skips KnownHosts. Pass it in an operation's options only if you set its host key callback yourself.

Diagnostics

Settings.Logger is a two-method seam (Debug, Warn) that a *slog.Logger satisfies directly, so 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.