DEV Community

wuddleko
wuddleko

Posted on

Generated files lie until you rerun the generator

Someone changes a protobuf field's type, or reuses a field number. The .proto is reviewed and merged. Nobody ran buf generate. CI is green: the Go still type-checks against the api.pb.go that was already in the tree. A client built from the new proto talks to a server built from the old one, and the compiler never said a word.

That's the tax on committing generated code. What's in Git is a snapshot of some past run. The thing that actually defines those files is the command that wrote them.

genguard re-runs that command and fails if the outputs don't match HEAD. It doesn't install your generators, and it doesn't commit anything. If you already check in protobuf, sqlc, OpenAPI, go generate, or a Makefile target, this is the gate. In CI, or with --isolated, it answers whether a fresh clone of this commit would produce the same files. A local check without --isolated writes your working tree.

The script you'd write if you were being careful

Most people start with buf generate && git diff --exit-code gen/. That's the short version. The careful one looks like this:

set -euo pipefail
rm -rf gen
buf generate
git diff --exit-code HEAD -- gen
test -z "$(git ls-files --others --exclude-standard -- gen)"
Enter fullscreen mode Exit fullscreen mode

HEAD, not the index, because that's what CI will check out. The ls-files line catches a file the generator started writing. The rm -rf is there so a file it stopped writing doesn't sit around looking identical to the last commit.

On a clean CI checkout, that script catches the stale api.pb.go above. It's enough for one generator. The rest of this post is why you still don't want to live in that script: skip logic, pinning the generator so CI isn't a different buf than your laptop, and a wipe you can actually aim.

Skipping a generator is the part nobody wants to maintain

The careful script always runs buf generate. That's fine until the run takes forty seconds and the PR only touched SQL.

A skip you can trust is more than git diff origin/main -- proto/. You need the merge-base of HEAD and that ref, not whatever main happens to be now, and you also have to compare against HEAD. You shouldn't skip a group that never declared inputs. A bad ref should fail the job before anything gets deleted. And you still have to re-run if any of these changed:

  • outputs
  • the config file
  • a listed output file is missing
  • something under those paths is untracked

Then you copy that predicate for sqlc, and again in the next service. People don't finish that script. Either every generator runs every time, or someone writes a path filter, misses a config change, and ships stale stubs.

genguard keeps the path lists next to the generated files and does the skip once. --since origin/main skips a group whose inputs, outputs, and config still match both the merge-base of that ref and HEAD. --all finds every genguard.yaml in the repo.

CI is a different buf than your laptop

The most common false drift isn't a forgotten regenerate. It's CI running a different generator version than you did:

tools:
  - name: buf
    version: 1.32.0
  - name: sqlc
    version: 1.27.0

clean: true
groups:
  - name: protobuf
    command: buf generate
    tools: [buf]
    inputs:
      - proto/
    outputs:
      - gen/
  - name: sqlc
    command: sqlc generate
    tools: [sqlc]
    inputs:
      - queries/
    outputs:
      - internal/db/
    clean: false  # hand-written files live here
Enter fullscreen mode Exit fullscreen mode

Use clean: true only where outputs hold nothing but generated files. genguard check runs the groups in order and only looks at those paths against HEAD. A missing or mismatched tool fails the group before clean and before the generator.

When the stubs are stale, you get this, then a git diff of the file:

Summary
  protobuf: drift (1 modified); buf 1.32.0
1 group: 0 ok, 1 drift, 0 error

Drift
[modified] protobuf: gen/api.pb.go

error: 1 generated path drifted;
commit the generator output or fix the command
Enter fullscreen mode Exit fullscreen mode

Leftover files, and a wipe that won't take .git with it

The type-change above shows up in git diff HEAD. This one doesn't. You delete old.proto, or rename it, or point the generator at a new output path. The generator stops writing old.pb.go. The file stays tracked, still compiles, still sits on the public API. git diff HEAD -- gen is empty because that file didn't change.

You only catch it if you delete gen/ first. With clean: true, a leftover file shows up like this:

[missing] protobuf: gen/old.pb.go
Enter fullscreen mode Exit fullscreen mode

Skip the wipe, and the leftover is invisible. Do a blanket rm -rf, and a failed buf generate leaves you with an empty directory in the checkout you were working in.

genguard will wipe when you set clean: true, and only the paths you listed. It refuses .git, the config file, a symlink, or anything outside the config directory. It doesn't put the files back if the command dies. --isolated runs the same check in a temporary worktree and leaves your checkout alone.

What it won't do

It won't install buf or put it on PATH. The tools pin fails a mismatch; it doesn't fetch the binary. The GitHub Action is the same: it installs genguard, not your generators.

If the generator isn't bit-stable, check will fail every time. That's the generator, not the checker.

It won't tell you the rest of the working tree is dirty. It only looks at outputs.

And it won't commit. You regenerate locally, commit, push. CI proves the snapshot.

Quick start

This is v0.7.0. The project is pre-1.0; flags have moved before.

go install github.com/wuddleko/genguard/cmd/genguard@v0.7.0
Enter fullscreen mode Exit fullscreen mode

Copy examples/buf.yaml to genguard.yaml next to the generated directory, not inside it, and fix the paths. Then:

genguard check
Enter fullscreen mode Exit fullscreen mode

A match prints Generated files match the generators. and exits 0. Drift exits 1 with the paths and a diff. Bad config, Git, or a crashed generator exits 2.

- uses: actions/checkout@v4
  with:
    fetch-depth: 0   # needed for --since
- uses: wuddleko/genguard@v0.7.0
  with:
    all: true
    since: origin/main
Enter fullscreen mode Exit fullscreen mode

Put buf, sqlc, and the rest on PATH in that job. Per-tool setup is in docs/ci.md.

github.com/wuddleko/genguard — more templates in examples/, internals in docs/design.md.

Top comments (0)