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:
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 asgithub.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:
- 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.pubbeside 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.pubhas no public half to compare, so it skips this step. - 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:
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:
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/configalias is resolved first, as go-git resolves it before dialling. The file pins theHostnameandPortthe 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 skipsKnownHosts. 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:
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:
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.
Related¶
- Settings reference: every field, default, and failure mode
- Errors reference: the exact message each misconfiguration produces
- Why git needs no forge
- Clone and commit