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.
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 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 "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:
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.