Why git needs no forge¶
This module clones from GitHub, GitLab, Bitbucket and Gitea, authenticates against all of them — and depends on none of them. That is deliberate, and it is worth explaining because the obvious design does the opposite.
The obvious design, and why it's wrong¶
Git-over-HTTPS authentication is forge-flavoured. Each forge wants a different username in the basic-auth header:
| Forge | Username |
|---|---|
| GitLab | oauth2 |
| Bitbucket | x-token-auth |
| everything else | x-access-token |
So a git layer plainly needs to know which forge it is talking to, and the natural move is to import the forge abstraction and read its constants and config. That is what this package used to do — it imported a release/forge package for exactly two things:
release.SourceTypeGitLab → "oauth2"
release.SourceTypeBitbucket → "x-token-auth"
vcs.ResolveToken(settings.Auth, fallbackEnv)
Look closely at what those actually are. The first is a name. The second is a token. Neither is a forge operation — no API call, no release listing, no vendor SDK. The dependency existed to carry two values that the caller already had.
Inverting it¶
So the caller passes them in:
type Settings struct {
Forge string // "gitlab", "bitbucket", … — just a name
Private bool // fail fast rather than trying unauthenticated
Token TokenSource // resolved lazily
// …
}
Forge is a plain string, not an enum, so you can name a forge this package has never
heard of and it will fall back to the GitHub convention. The Forge* constants are
conveniences, not a closed set.
The result: go list -deps for this module contains no forge package and no forge
SDK — a guard test asserts exactly that, so the coupling cannot quietly return the
next time someone needs a constant.
What that buys¶
- You don't pay for what you don't use. Depending on a forge abstraction means
dragging its providers' SDKs —
go-github, GitLab's client — into the graph of every program that just wanted to clone a repository. - Any forge works, including ones we've never heard of. Self-hosted, internal, brand new: pass its name and the right token.
- The layers stay independently useful. Release management and git operations are genuinely different jobs with different callers; nothing forces you to adopt both.
The same reasoning removed a second, less obvious dependency: the framework logger this
package once used pulled an entire terminal-UI stack transitively. The
Logger seam here is two methods wide — Debug and Warn —
which *slog.Logger satisfies directly, so no logging library is imposed either.
Why the token is a function¶
Token is a TokenSource (func() string), not a resolved string. That looks like
over-engineering until you consider where tokens live.
Resolving one can be expensive or interactive — reading the OS keychain may prompt the user to unlock it. And token auth is not always used: SSH authentication takes priority, so a repository configured for SSH needs no token at all.
An eagerly-resolved string would mean every caller paid for a keychain lookup they might never use — including SSH users, who would be prompted for a credential the operation does not want. A lazy source is called only on the code path that actually authenticates with a token:
// resolved on demand, and only if the token path is taken
Token: func() string { return myKeychain.Get("github") },
// or, when you already have it
Token: repo.StaticToken(os.Getenv("GITHUB_TOKEN")),
The module's own test suite asserts this: a case configured for SSH installs a token source that panics if called, so the laziness is enforced rather than assumed.
Settings is the whole input surface¶
Injecting the token but reading an environment variable for the SSH key path, or for a
debug switch, would half-undo the same design: construction would again depend on ambient
process state that no caller passed and no test controls. So the rule is unconditional —
this package reads no environment of its own. Everything arrives through Settings,
including the SSH key path (a resolved path, never a variable name) and Progress.
That buys three things. Construction is fully determined by its arguments, so the same
Settings behaves the same in a test, a CI job, and production. A setting is scoped to
one repository rather than the whole process — two repositories in the same program can
trace differently, or authenticate from different key files. And it rules out the pattern
this replaced: a package-level variable set from the environment in init(), which races
under t.Parallel() and cannot be scoped or overridden at all.
Environment fallback is still the right behaviour for most applications — it just belongs in the caller's composition root, next to the rest of its configuration. So the precedence is offered as a helper the caller invokes, never something the package does behind its back:
KeyPath is the single function in the module that touches the environment, and nothing
in the module calls it. A test walks the package's own AST to prove that
(TestEnvFootprint), so the claim is enforced rather than documented — the same tactic as
the dependency guard above.