<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[wuddleko]]></title><description><![CDATA[wuddleko]]></description><link>https://wuddleko.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>wuddleko</title><link>https://wuddleko.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 08 Oct 2026 07:03:14 GMT</lastBuildDate><atom:link href="https://wuddleko.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Generated files lie until you rerun the generator
]]></title><description><![CDATA[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 alre]]></description><link>https://wuddleko.hashnode.dev/generated-files-lie-until-you-rerun-the-generator</link><guid isPermaLink="true">https://wuddleko.hashnode.dev/generated-files-lie-until-you-rerun-the-generator</guid><category><![CDATA[ci]]></category><category><![CDATA[Devops]]></category><category><![CDATA[generators]]></category><category><![CDATA[Git]]></category><category><![CDATA[Go Language]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[ #githubactions]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[wuddleko]]></dc:creator><pubDate>Thu, 01 Oct 2026 13:19:35 GMT</pubDate><content:encoded><![CDATA[<p>Someone changes a protobuf field's type, or reuses a field number. The <code>.proto</code> is reviewed and merged. Nobody ran <code>buf generate</code>. CI is green: the Go still type-checks against the <code>api.pb.go</code> 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.</p>
<p>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.</p>
<p><a href="https://github.com/wuddleko/genguard">genguard</a> re-runs that command and fails if the outputs don't match <code>HEAD</code>. It doesn't install your generators, and it doesn't commit anything. If you already check in protobuf, sqlc, OpenAPI, <code>go generate</code>, or a Makefile target, this is the gate. In CI, or with <code>--isolated</code>, it answers whether a fresh clone of this commit would produce the same files. A local <code>check</code> without <code>--isolated</code> writes your working tree.</p>
<h2>The script you'd write if you were being careful</h2>
<p>Most people start with <code>buf generate &amp;&amp; git diff --exit-code gen/</code>. That's the short version. The careful one looks like this:</p>
<pre><code class="language-bash">set -euo pipefail
rm -rf gen
buf generate
git diff --exit-code HEAD -- gen
test -z "$(git ls-files --others --exclude-standard -- gen)"
</code></pre>
<p><code>HEAD</code>, not the index, because that's what CI will check out. The <code>ls-files</code> line catches a file the generator started writing. The <code>rm -rf</code> is there so a file it stopped writing doesn't sit around looking identical to the last commit.</p>
<p>On a clean CI checkout, that script catches the stale <code>api.pb.go</code> 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 <code>buf</code> than your laptop, and a wipe you can actually aim.</p>
<h2>Skipping a generator is the part nobody wants to maintain</h2>
<p>The careful script always runs <code>buf generate</code>. That's fine until the run takes forty seconds and the PR only touched SQL.</p>
<p>A skip you can trust is more than <code>git diff origin/main -- proto/</code>. You need the merge-base of <code>HEAD</code> and that ref, not whatever <code>main</code> happens to be now, and you also have to compare against <code>HEAD</code>. 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:</p>
<ul>
<li><p>outputs</p>
</li>
<li><p>the config file</p>
</li>
<li><p>a listed output file is missing</p>
</li>
<li><p>something under those paths is untracked</p>
</li>
</ul>
<p>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.</p>
<p>genguard keeps the path lists next to the generated files and does the skip once. <code>--since origin/main</code> skips a group whose inputs, outputs, and config still match both the merge-base of that ref and <code>HEAD</code>. <code>--all</code> finds every <code>genguard.yaml</code> in the repo.</p>
<h2>CI is a different <code>buf</code> than your laptop</h2>
<p>The most common false drift isn't a forgotten regenerate. It's CI running a different generator version than you did:</p>
<pre><code class="language-yaml">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
</code></pre>
<p>Use <code>clean: true</code> only where <code>outputs</code> hold nothing but generated files. <code>genguard check</code> runs the groups in order and only looks at those paths against <code>HEAD</code>. A missing or mismatched tool fails the group before <code>clean</code> and before the generator.</p>
<p>When the stubs are stale, you get this, then a <code>git diff</code> of the file:</p>
<pre><code class="language-plaintext">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
</code></pre>
<h2>Leftover files, and a wipe that won't take <code>.git</code> with it</h2>
<p>The type-change above shows up in <code>git diff HEAD</code>. This one doesn't. You delete <code>old.proto</code>, or rename it, or point the generator at a new output path. The generator stops writing <code>old.pb.go</code>. The file stays tracked, still compiles, still sits on the public API. <code>git diff HEAD -- gen</code> is empty because that file didn't change.</p>
<p>You only catch it if you delete <code>gen/</code> first. With <code>clean: true</code>, a leftover file shows up like this:</p>
<pre><code class="language-plaintext">[missing] protobuf: gen/old.pb.go
</code></pre>
<p>Skip the wipe, and the leftover is invisible. Do a blanket <code>rm -rf</code>, and a failed <code>buf generate</code> leaves you with an empty directory in the checkout you were working in.</p>
<p>genguard will wipe when you set <code>clean: true</code>, and only the paths you listed. It refuses <code>.git</code>, the config file, a symlink, or anything outside the config directory. It doesn't put the files back if the command dies. <code>--isolated</code> runs the same check in a temporary worktree and leaves your checkout alone.</p>
<h2>What it won't do</h2>
<p>It won't install <code>buf</code> or put it on <code>PATH</code>. The <code>tools</code> pin fails a mismatch; it doesn't fetch the binary. The GitHub Action is the same: it installs genguard, not your generators.</p>
<p>If the generator isn't bit-stable, <code>check</code> will fail every time. That's the generator, not the checker.</p>
<p>It won't tell you the rest of the working tree is dirty. It only looks at <code>outputs</code>.</p>
<p>And it won't commit. You regenerate locally, commit, push. CI proves the snapshot.</p>
<h2>Quick start</h2>
<p>This is v0.7.0. The project is pre-1.0; flags have moved before.</p>
<pre><code class="language-bash">go install github.com/wuddleko/genguard/cmd/genguard@v0.7.0
</code></pre>
<p>Copy <a href="https://github.com/wuddleko/genguard/blob/main/examples/buf.yaml">examples/buf.yaml</a> to <code>genguard.yaml</code> next to the generated directory, not inside it, and fix the paths. Then:</p>
<pre><code class="language-bash">genguard check
</code></pre>
<p>A match prints <code>Generated files match the generators.</code> and exits 0. Drift exits 1 with the paths and a diff. Bad config, Git, or a crashed generator exits 2.</p>
<pre><code class="language-yaml">- uses: actions/checkout@v4
  with:
    fetch-depth: 0   # needed for --since
- uses: wuddleko/genguard@v0.7.0
  with:
    all: true
    since: origin/main
</code></pre>
<p>Put <code>buf</code>, <code>sqlc</code>, and the rest on <code>PATH</code> in that job. Per-tool setup is in <a href="https://github.com/wuddleko/genguard/blob/main/docs/ci.md">docs/ci.md</a>.</p>
<p><a href="https://github.com/wuddleko/genguard">github.com/wuddleko/genguard</a> — more templates in <code>examples/</code>, internals in <a href="https://github.com/wuddleko/genguard/blob/main/docs/design.md">docs/design.md</a>.</p>
]]></content:encoded></item></channel></rss>