Codegen
Most targets keep their outputs inside heph's cache and sandbox — the source
tree never changes. Codegen targets are the exception. They materialize
their outputs back into the workspace, next to your hand-written code, so that
tools which read the tree directly — your editor, the compiler, go list,
grep — see the generated files as if you had written them yourself.
The codegen attribute on an exec target turns this on and picks how the
outputs land in the tree:
target(
name = "generated",
driver = "bash",
codegen = "copy", # or "in_place"
out = "generated.txt",
run = "echo 'generated by //fmt:generated' > $OUT",
)
The two modes
| Mode | Produces | Tracked in git? | Needs gitignore? |
|---|---|---|---|
copy | New files the target creates | No | Yes |
in_place | Rewrites of existing sources | Yes | No |
The distinction is about ownership of the file on disk:
-
copyemits a brand-new file that did not exist in the tree before. It is a build artifact that happens to live among your sources — not a tracked source. heph stamps these outputs so a laterglob()excludes them (they won't be picked up as inputs to other targets), and they belong in.gitignorerather than in a commit. -
in_placetransforms files that are already tracked and committed. The target reads a source file and writes the result back over the same path. The file stays a normal, version-controlled source — there is nothing new to ignore.
copy — generate a new file
Use copy when the target creates output that you do not want to commit:
protobuf stubs, generated API clients, embedded asset manifests — anything
derived purely from other inputs.
target(
name = "generated",
driver = "bash",
codegen = "copy",
out = "generated.txt",
run = "echo 'generated by //fmt:generated' > $OUT",
)
//fmt:generated writes fmt/generated.txt into the tree. Because it is a
copy output, glob("*.txt") in the same package will skip it, and
heph tool gen-gitignore will add it to the root .gitignore.
in_place — rewrite existing sources
Use in_place for tools that modify files you keep under version control:
formatters, codemods, an auto-fixing linter. The generated result replaces the
checked-in file, so the change shows up as a normal diff you review and commit.
target(
name = "fmt",
driver = "bash",
codegen = "in_place",
deps = {"src": glob("*.txt")},
out = glob("*.txt"),
# Idempotent: uppercase every tracked .txt file, writing it back in place.
run = 'for f in $SRC_SRC; do tr a-z A-Z < "$f" > "$f.up" && mv "$f.up" "$f"; done',
)
Keep in_place transforms idempotent — running them twice should produce
the same bytes as running them once. That keeps the output reproducible and the
diff stable.
Keeping .gitignore in sync: heph tool gen-gitignore
copy outputs should never be committed, so heph can maintain the .gitignore
for you. Running:
heph tool gen-gitignore
scans the workspace for every codegen = "copy" output and writes them into
a managed block in the root .gitignore:
# BEGIN heph-generated (managed by `heph tool gen-gitignore` — do not edit)
/fmt/generated.txt
# END heph-generated
The command is:
- Scoped. Only
copyoutputs are listed —in_placerewrites are tracked sources and never appear. - Anchored. Paths are workspace-root-relative, so a file becomes
/foo/bar.go, a directory/foo/gen/, and a glob/foo/gen/**/*.go. - Idempotent & non-destructive. Only the marked block is rewritten; anything you put outside the markers is preserved verbatim. Output is sorted and deduplicated, so re-running it produces a stable, diffable result.
Run it after adding or removing a copy target. Because the output is
deterministic, it also works well as a CI check — fail the build if
heph tool gen-gitignore would change the committed .gitignore.
Detecting output conflicts
Two copy targets must never claim overlapping output paths — if they do, one
target's output silently overwrites the other's. Run heph validate to catch
these conflicts across the whole workspace:
heph validate
Overlap means more than identical paths. heph also flags containment: if one
target claims the directory /gen/ and another claims the file /gen/a.go,
they overlap because the directory output encompasses the file. A file and a
same-named directory (trailing slash ignored) also conflict.
Only conflicts between different targets are reported — a single target that declares both a directory output and a file inside it is valid.
Run heph validate in CI alongside heph tool gen-gitignore to ensure no two targets
compete for the same part of the source tree.
Verifying the tree in CI: --frozen
Running a codegen target normally writes its outputs into the tree — copy
drops in the generated file, in_place rewrites the source. In CI you usually
want the opposite: assert that the committed tree already matches what codegen
would produce, without touching anything.
That is what --frozen does:
heph run //fmt:fmt --frozen
In frozen mode heph computes the generated output but writes nothing. It compares the result against what is on disk and, if they differ, exits non-zero with a unified diff of every offending file:
$ heph run //fmt:fmt --frozen # clean tree → passes
$ echo 'hello world' > fmt/greeting.txt
$ heph run //fmt:fmt --frozen # dirty tree → fails
× target failed: //fmt:fmt
╰─▶ generated output differs from tree
╭─[diff]
│ --- a/fmt/greeting.txt
│ +++ b/fmt/greeting.txt
│ @@ -1 +1 @@
│ -hello world
│ +HELLO WORLD
╰────
It works for both modes: a copy target fails if its generated file is missing
or stale, and an in_place target fails if a source file isn't already in its
formatted/transformed form. Wire heph run <codegen-target> --frozen into CI to
guarantee that whoever forgot to run codegen locally gets a red build with an
exact diff, instead of a drifting tree.
When to use which
| You want to… | Mode |
|---|---|
| Generate code from a schema (proto, OpenAPI, SQL) | copy |
| Produce a derived file you don't commit | copy |
Format source files (gofmt-style) | in_place |
| Run a codemod or auto-fixing linter over checked-in code | in_place |
The quick rule: if the file is born from the build, use copy and let
heph tool gen-gitignore keep it out of git. If the file is yours and the build edits
it, use in_place and commit the result.