The problem it exists for
A program that works with version control talks to two different things. One is the forge, the website and API around the code: its releases, merge requests, issues and wikis. The other is git itself: cloning, committing, branching and tagging. These modules cover both, and neither needs a forge’s SDK or a git binary installed.
go/forge has a narrow job: letting a program work with a forge properly, the same way whichever forge it is. It started inside go-tool-base as a thin layer over releases, finding a tool’s latest release and downloading its assets so the tool could update itself. Once it was its own module it grew quickly, because programs want far more from a forge than its releases: merge requests, merging, issues, comments, wikis and the rest.
The hard part is that no two forges do the same thing the same way. GitHub, GitLab, Gitea and Bitbucket each have their own idea of what a release, a merge request or a label is, and Bitbucket simply doesn’t have some of them at all. go/forge is one consistent interface over all of that, so a tool written against it doesn’t need four code paths, or four vendors’ SDKs in its build.
There’s a less obvious reason too. A forge’s own answers can’t always be trusted. On GitLab, the rebase that happens just before a fast-forward merge is never written back to the merge request, so the head commit the merge request records can be one that sits on no branch at all. A release tool that believes it tags the wrong commit, quietly. Measured across one group of my own modules on 1 August 2026, 9 of 231 tags weren’t on their default branch (spec 0010).
On the git side, go/repo starts from the fact that git doesn’t know what a forge is. It wants a URL and a credential, so cloning from GitHub shouldn’t put a GitHub SDK in your build either. It sits on go-git, git written in pure Go, which is powerful and raw. Its biggest catch is concurrency: go-git isn’t safe to use from more than one goroutine at once, even for reads, so a server or a tool doing several things in parallel can corrupt go-git’s caches just by reading. go/repo’s headline job is making go-git safe to share.
How it works
A small core, and everything else optional
Every forge has to do four things: find the latest release, find one by tag, list releases, and download a release asset. Everything beyond that (merge requests, merging, publishing releases, issues, comments, wikis, pages and more, nineteen of them) is an optional capability, each one separately present or absent, and a caller checks before it asks. Adding a new capability never breaks an existing provider, including somebody else’s, and the capability matrix says never where a platform simply can’t do something and not yet where the code hasn’t been written.
One module per forge
Each forge’s SDK lives in an adapter of its own (GitHub, GitLab, Gitea and Codeberg, Bitbucket), plus a plain “direct” provider for download URLs that needs no SDK at all. The core can’t take on any of them: two tests fail the build if a forge SDK, or even an instrumented HTTP stack, reaches its dependency graph. A shared conformance suite holds every adapter to the same contract, and a provider registers itself by name, so a forge I’ve never heard of can be added as another person’s module.
Careful with what it trusts
A token is pinned to the host it was issued for, so a hostile release can’t redirect it somewhere else. And “which commit actually merged” is only ever answered after checking that the target branch contains it, never from the merge request’s say-so.
go-git, made safe to share
go-git updates its internal caches even when it’s only reading, so two goroutines reading the same repository can corrupt those caches. A read-write lock looks like the obvious fix and isn’t one, because the reads write too. go/repo’s thread-safe repository puts every operation behind one exclusive lock instead, reads included, and the rest of the design makes that lock hard to slip past:
- A filesystem handle taken from it keeps taking the lock after it’s been passed somewhere else, and so does every file opened through it, so code that knows nothing about the lock still can’t touch the working tree unsafely. There’s no way to reach the unlocked filesystem underneath.
- A sequence that has to happen as one, such as writing two files and committing them, runs inside a callback that holds the lock for the whole sequence.
- Reaching past the wrapper to go-git itself also happens inside a callback, under the same lock, so even the operations go/repo doesn’t wrap stay safe.
It’s safe mostly, and the edges are deliberate. The lock isn’t reentrant, so calling the repository from inside one of its own callbacks deadlocks. A go-git pointer kept after its callback returns, or one handed out at setup time, is back outside the lock. And the lock makes sharing safe rather than faster: operations queue up behind each other.
A real repository, held in memory
go/repo can clone a repository into memory and work on it there: read files, commit, branch, tag and push, with nothing written to disk. A tag is always annotated, is made at a full commit SHA the caller has already settled on rather than whatever a name resolves to, and refuses to overwrite a tag that already exists. It isn’t a mock. It’s a real git repository, so it also makes a test fixture with no fake behaviour to drift from real git, and nothing to clean up afterwards. The same API works against a checkout on disk.
A sparse checkout clones a large repository but fills the working tree with only the directories you name, in one option. On a test repository of 300 files it cut the memory a clone held from 12.6 MiB to 5.4 MiB. The history is still fetched in full, so the saving is the working copy.
The working tree is an ordinary filesystem
The checked-out files are exposed as an afero filesystem, the interface much of the Go ecosystem already reads and writes through, by way of aferobilly. It’s a live view rather than a copy, so a file written through it is a file in the working tree, with no sync step.
Signing in to any forge without importing one
Cloning or pushing over HTTPS means sending a username and a token, and each forge expects a different username beside the token: oauth2 on GitLab, x-token-auth on Bitbucket, and x-access-token on GitHub, Gitea and Codeberg. So a git library has to know which forge it’s talking to. The obvious way to know is to import a forge library, and go/repo used to do exactly that, pulling in a whole release-and-forge package to get two values the caller already had: a name and a token.
Now the caller passes them in. The forge is a plain name, so an unknown one, a self-hosted or brand-new forge, simply gets the GitHub convention and works without a code change. A test fails the build if any forge package or SDK reaches go/repo’s dependencies, so the coupling can’t creep back.
The token is fetched only if it’s actually used. SSH takes priority over a token, and reading a token can mean unlocking the system keychain, so a repository set up for SSH must never trigger that prompt. The tests prove it with a token source that crashes if it’s ever called.
And a changelog
Beside both sits go/changelog, which builds a changelog from a repository’s Conventional Commits, and can read an existing set of release notes back into a structure a program can query.
Decisions and what they cost
- Every forge, or none. No capability ships for one forge alone. GitHub’s adapter came out of go-tool-base carrying merge requests, repository creation and file contents, and only its releases came across, because a GitHub-only method would compile, pass its tests and still make the portability claim false (spec 0001). A capability lands when all four adapters can do it, or a platform genuinely can’t and says so. What it cost: that GitHub code waited until the other three caught up.
- No recorded head at all. The contract has no field for the commit a merge request claims to have merged. The answer comes back only once the target branch is confirmed to contain it (spec 0010), because checking the commit merely exists isn’t enough when the orphaned one still exists too. What it cost: on a rebased merge request, a walk of up to a hundred commits where one field read would have done.
- Credentials, caller’s choice. The built-in four-step lookup for tokens was deleted outright, and the caller now decides where credentials come from (spec 0005). What it cost: a clean break with no shim, felt downstream, in exchange for a core that no longer depends on a credentials library.
- Verify the release commit. GitHub and GitLab both silently ignore the target commit once a tag exists, so the provider verifies it instead of passing it along, and an absent tag is refused rather than created (spec 0013). What it cost: a naming decision reversed after a release had shipped, when short method names collided across all four adapters, and a test now catches that kind of collision.
- Merging, but people decide. Merging was first ruled out of the library entirely. That got reversed, on the basis that the house rule is about who merges, not what the code can do (spec 0024). What it cost: “merge when ready” is refused on Bitbucket, where it can’t be checked below a premium workspace.
- Linked worktrees just work. Opening a repository inside a linked worktree (the extra checkout
git worktree addmakes) used to hand back a healthy-looking repository that failed later, with an error about missing objects that never mentioned worktrees. The fix is always on rather than an option, because an option would only exist to let someone ask for the broken behaviour (repo spec 0001). What it cost: a worktree pointing at a repository that’s gone now fails when it’s opened, which is the better place to fail. - Ask for the narrowest part. go/repo is split into ten small roles (reading the tree, committing, branching and so on), so a function that only reads files asks for only that, and a two-method fake can stand in for it in a test. Each role ships a mock, and the build fails if a mock stops matching its role. What it cost: most of go-git isn’t wrapped (fetch, log, merge, status and the rest), so a caller reaches go-git directly through a callback, still under the lock, and gives up the narrow contract while they’re there.
Proof in use
- colophon is built on all of it. It uses all four forge adapters, tags its releases from an in-memory clone, and records each release in this blog with a sparse clone of the one directory it writes to, without a disk or a git binary.
- phpbotscout uses go/forge and go/repo together. go/forge finds which repositories across the estate have a docs site, by checking each one for its site’s config file, and go/repo shallow-clones each of those into memory so the bot can index the docs.
- skillup uses both too. Its GitLab adapter comes through go-tool-base, and every history question it asks and every commit and push it makes goes through go/repo, with nothing shelled out to a git binary. It often runs inside a linked worktree.
- go-tool-base uses the forge core, repo and changelog, keryx uses go/forge and go/repo, and krites and sigillum talk to GitLab through go/forge.
- 183 releases across the forge family since July 2026, 42 of them the core, plus 10 of go/repo, 10 of go/changelog and 6 of aferobilly.
- Every forge adapter runs the same conformance suite, and every adapter’s release merge request runs cicd’s go-core-currency check, which flags an adapter about to ship against an older go/forge.
Use it when, and when not to
Use go/forge if a Go tool needs to read or cut releases, or work with merge requests, on more than one forge, or might one day, and you’d rather not carry every vendor’s SDK to do it. Use go/repo if a Go program needs to clone, commit or push without a git binary on the machine, needs to share one repository across goroutines, or wants a real repository in memory for its tests.
Know where the edges are. GitLab is the one proven by daily use; the others are supported by their adapters and tests rather than by me running them in anger. go/forge doesn’t retry, back off or cache, it downloads checksums and signatures without verifying them (that’s go/signing’s job), and some forges can’t answer some questions at all: Bitbucket can’t list releases or find one by tag, and has no issues. go/repo reads files from the current commit only, not another revision, the staging area or uncommitted changes, and named remotes aren’t available on the thread-safe repository. The forge limitations and repo limitations pages have the full lists. Both are pre-1.0, so pin them.
Where it’s going
Most of what’s left is waiting on other people: merge-when-ready on Bitbucket needs a premium workspace to test against, a panic in the Gitea SDK is written up for its maintainers, and a symlink failure in go-git waits on go-git v6, whose pre-releases already fix most of it.
Adapters, and the libraries beside them
- aferobilly Use a go-billy filesystem anywhere an afero one is expected: a complete billy-to-afero adapter with optional locking that makes a live handle concurrency-safe.
- changelog Turn a repository's Conventional-Commits history, or a release-notes archive, into a structured, categorised changelog.
- forge-bitbucket Bitbucket release provider, over the Downloads API.
- forge-gitea Gitea and Codeberg release provider, over the gitea SDK.
- forge-github GitHub release provider (github.com and Enterprise), over go-github.
- forge-gitlab GitLab release provider, over the official client-go.
- repo Git repository operations for Go: clone, commit, worktrees and tree inspection over go-git, behind focused role interfaces.
Last reviewed .