Featured image of post Eighty-one megabytes of hello world

Eighty-one megabytes of hello world

How big is a tool that does nothing? For go-tool-base it was 81 MB, and a comment in the code said it shouldn't have been.

There’s a question I should have asked go-tool-base about a year before I did (give or take), and it’s a slightly rude one to ask any framework: how big is a tool that does nothing?

I finally got round to asking it one morning in September, and it came back with… 81 MB.

Everything, and the kitchen sink

I’d been wiring more chat providers into the framework (the local ones, claude-local and codex-local, because I’m a cheapskate and the metered API bills were getting silly), and each new provider I added was another module for any tool built on the framework to carry. That nagged at me, a tad. A project generated with gtb imports go-tool-base, and go-tool-base at the time imported the lot, each chat adapter and each forge adapter it knew about, so a tool got OpenAI’s SDK, Anthropic’s, Google’s auth stack, go-github and the GitLab API client whether it talked to any of them or not. colophon uses forges and no chat provider, keryx uses chat and one forge, and each was carrying the other’s SDKs.

I knew this, more or less. I’d told myself it was fine because there was a way out. Two files in pkg/ did the registering, and both carried a comment saying so: one for chat said “a regulated downstream that wants a lighter binary can build its own adapter and import only the providers it needs”, and the other for forges said “a tool that needs only one forge can drop the others: replace this file’s effect by importing just the providers you want”. Very reassuring, and written by me some months earlier with what I’m sure felt like care at the time, and not a word of it was true.

Go has no way to un-import

A blank import in Go (import _ "some/module") exists to run that module’s init() for its side effects, and the side effect here is an adapter registering itself with the framework’s registry. I wrote about that pattern approvingly in April: put the blank import in the tool’s own main, and deleting one line drops that adapter and its SDK from the binary. The keychain backend worked like that, and so did the signing backends.

The chat providers and the forges didn’t. Their blank imports sat in pkg/chat and pkg/setup, and pkg/setup is imported by pkg/cmd/root, which is the root command any tool built on the framework uses. So the imports arrived through the front door, with the framework, and a downstream tool had about as much say in the matter as it has over which packages the standard library links. You can add imports in Go, but you can’t subtract one that something you depend on has already made, and the comment was describing something you simply couldn’t do.

I don’t think I’d have found it by reading, so I ran a spike, an hour timeboxed: a main that calls root.NewCmdRoot and not much else, built static and stripped, then the two provider files emptied and the same build again.

with the adapters in pkg/without
stripped binary81,326,240 bytes34,312,352 bytes
packages linked948812
modules in the closure171136

Fifty-eight percent of hello world was… SDKs for services it had no plans to call! Twenty-three packages of openai-go, twenty-three of the Anthropic SDK, sixteen of Google’s auth library, the GitLab API client in full. The 34 MB that remained is a separate conversation (grpc, go-git, protobuf, OpenTelemetry and the charm TUI stack, all framework-side, all things a tool might plausibly use) and I’ve not had it yet.

One binary needs the sink

The fix was fairly obvious once the number was in front of me. The keychain had already shown me the shape of it: the registering belongs in a tool’s main, and the framework had no business doing it on the tool’s behalf.

Only one thing in the repository needs all the adapters, and that’s gtb itself, the CLI that generates projects. Its wizard asks about each forge and each chat provider, so it has to link them all; it is, in the comment I gave it, the kitchen-sink binary. The framework doesn’t need the sink and neither does anything built on it. So the CLI moved into its own module at cli/, nested in the same repository, with its nine blank imports in its own main where they belong, and the two provider files in pkg/ were deleted. The framework’s go.mod went from 68 direct requires to 51.

Then the generator learned to emit only what a tool asks for. It didn’t need a new question for forges, it already asks one: enable the gitlab feature and you get go/forge-gitlab linked, and a tool that enabled a forge it hadn’t linked would fail its update check at runtime anyway. Chat providers became a list on the manifest. Each family gets one file in the generated cmd/<name>/, one blank import per line, regenerated from the manifest, which is the keychain’s shape from April applied across the board.

The closing check in the spec: a project generated with claude and gitlab selected tidies clean, emits a one-line chat.go and a one-line forge.go, and go version -m on the built binary shows chat-anthropic and forge-gitlab and no other adapter. Seventy-two megabytes, and that one has a chat provider and a forge in it and does some work.

The bill came two days later

Splitting a repository into two modules does have a price, though, and I paid it inside the week.

A nested module that depends on its parent has no precedent in this estate (the two we had, go/controls/lint and a capability generator, are standalone), and Go shapes it for you: go install path@version refuses a module whose go.mod carries a replace, so the CLI couldn’t just point at ../. The spec went with a committed go.work for in-repo builds and a cli/go.mod requiring the framework’s last release, which sounds fine and means a CLI change can’t use framework API landed in the same merge request, or in any merge request since the last tag, until a release is cut. Two days in, a change touching both hit that (of course it did), and I found myself doing a two-step deployment through the very workspace I’d set up to avoid two-step deployments. That felt massively counter-intuitive and I said so, at some length.

The way out was to stop pretending go install was an install path. It hadn’t really been one for a while: the install scripts, the Homebrew cask, self-update and just install all build framework and CLI from one commit through the workspace, so the pairing already held anywhere a person gets the binary. The docs now say don’t use go install for gtb, the guard job that enforced the lag is gone, and colophon tags cli/vX.Y.Z beside each root tag so the two version series move in lockstep. The require line in cli/go.mod is kept current by Renovate at each release, for tooling’s sake, and not much depends on it being fresh.

I wouldn’t call that solved. Settled, for now, until the day something needs the CLI on its own version series, and I’d pay it again for the 47 MB.

The comment that said I didn’t need to look

It’s the comment that bothers me more than the size, if I’m honest.

For the best part of a year two files said, in good faith, that a downstream could opt out, and with them saying so it went unchecked, by me most of all. I think an opt-out that’s never been tried is worse than not having one, because a missing one is a constraint you know about and design around, and an untried one is a reassurance that lets the problem sit (81 MB of it, in this case).

Built with Hugo · Theme Stack designed by Jimmy