I’m building a tool called keryx, and the part of it that matters here is its studio: a browser app where the work happens, which saves everything you do into a git repository behind the scenes, the way a developer’s project lives in git with a history you can step back through.
I wanted that repository to be able to live entirely in memory. Cloned, edited, committed and pushed without ever writing a working copy out to a disk, for the times when you can’t, or would rather not, leave a checkout sitting around on the machine. It sounds exotic, but it’s something git libraries genuinely support, and it’s exactly what a browser studio running on a server somewhere wants.
Getting it working needed one small, awkward piece of plumbing in the middle. And a few lines into writing that piece, I stopped, because I realised I was writing it in the wrong repository.
The bridge I was about to vendor
Here’s the awkward bit. All of keryx’s file handling goes through afero, the standard filesystem interface in the Go world, the thing you hand your code so it neither knows nor cares whether it’s talking to a real disk, a test fake, or memory. It’s the interface go-tool-base hands you for filesystem work. But an in-memory git repository, the kind go-git gives you with its memfs, doesn’t speak afero. It speaks go-billy’s filesystem interface instead. Two perfectly good filesystem abstractions, and a worktree on the wrong side of the gap from all my code.
What I needed was an adapter: a bridge that makes a billy filesystem look like an afero.Fs, so the studio’s existing file handlers work unchanged over a repo that lives entirely in RAM. Twenty minutes of work, maybe. The obvious move was to write it inside keryx and get on with my afternoon.
And that’s the move I caught myself making. Because a billy-to-afero bridge is not a keryx thing. It’s not even a studio thing. It’s a general capability that any tool built on go-tool-base might want the moment it touches git. Vendor it in keryx and I’ve buried a reusable bit of plumbing inside one consumer, where it will drift away from the framework and get reinvented, slightly differently, in the next tool I build that needs it.
The bridge belonged in the framework. So that’s where I put it.
A feature request, against myself
I wrote the need up properly. Not a code comment, not a mental note, but an actual feature request, with a reference implementation sketched out, dropped into the go-tool-base repository as a document for the framework to act on.
There’s something slightly absurd about filing a feature request against your own project. The author and the customer are the same person. But that’s exactly what gives it its value. The most useful design input a framework gets is a real consumer hitting a real wall, and for once I was both: the person who maintains go-tool-base, and the person downstream of it who’d just discovered something it couldn’t yet do. The request wasn’t hypothetical or “wouldn’t it be nice”. It was “I am stuck on this right now, here is precisely what it can’t do yet.”
What came out the other side is pkg/vcs/repo/aferobilly, a first-class part of the framework as of v0.22.0. Its own description is the clearest summary of what it is:
// Package aferobilly adapts a go-billy/v5 Filesystem to an afero.Fs. It is the
// pure, reusable bridge behind pkg/vcs/repo's worktree-as-afero accessors, but
// works for any billy filesystem (memfs, osfs, chroot).
Alongside it, the worktree itself grew the accessors that hand you that view: WorkFS() for a live afero handle, and WithWorkFS() for an atomic sequence (worktree_fs.go, and the adapter itself). keryx then consumed it like any other framework feature, and the in-memory studio fell into place.
Two sessions, one dependency
The bit I’d actually recommend to anyone is what I did with my time while that got built.
I didn’t down tools and wait for the adapter. I handed the feature request to a separate agent session and let it build the framework feature from the spec, working in the go-tool-base repo, while my keryx session carried straight on with all the studio work that didn’t depend on the bridge. Two sessions running in parallel, deliberately sequenced around the one dependency between them: keryx needs the adapter, so the adapter session goes first, but only the last mile of keryx actually waits on it. When go-tool-base cut the release with the adapter in it, keryx pulled the new version and the final piece slotted in.
That’s a workflow the framework split makes possible. The thing that’s a shared capability gets built once, in its proper home, by one stream of work, while the thing that consumes it carries on in another. The dependency between them is real, so the order matters, but only at the very end.
The one rule that came with it
Upstreaming it also meant the tricky part got solved properly, once, with a warning attached, rather than learned the hard way in a consumer. The adapter is concurrency-safe by construction: it serialises every operation through a lock, so when that lock is the same mutex guarding the repo, a live afero handle over the worktree is genuinely safe to share. But that safety has a sharp edge, and the package says so plainly:
// A handle (and its open files) must NOT be used from inside a critical section
// that already holds the same locker (the repo mutex is non-reentrant — that
// would deadlock).
Use the handle inside a WithWorkFS callback and you’ll re-lock a non-reentrant mutex and hang yourself. That’s exactly the kind of footgun that, vendored in keryx, I’d have discovered at 11pm with a wedged process and no idea why. In the framework, it’s documented at the source, where the next consumer reads it before they trip over it.
The truest test of a framework
Building a real product on your own framework is the best test of it, and this is what that actually looks like in practice. The test is sharper than “does it work”. It’s “what does the product need that the framework doesn’t have yet”, and every real answer to that is a feature request waiting to be filed.
The discipline is filing it against the framework instead of patching around it in the app. Do that, and the awkward bridge has exactly one home, the deadlock warning gets written down once, and the next tool I build inherits all of it for free. The customer was me. The feature request was real. And go-tool-base is better for my having been stuck.
