Skip to main content

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:

BUILD
target(
name = "generated",
driver = "bash",
codegen = "copy", # or "in_place"
out = "generated.txt",
run = "echo 'generated by //fmt:generated' > $OUT",
)

The two modes

ModeProducesTracked in git?Needs gitignore?
copyNew files the target createsNoYes
in_placeRewrites of existing sourcesYesNo

The distinction is about ownership of the file on disk:

  • copy emits 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 later glob() excludes them (they won't be picked up as inputs to other targets), and they belong in .gitignore rather than in a commit.

  • in_place transforms 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.

fmt/BUILD
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.

fmt/BUILD
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',
)
tip

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:

.gitignore
# BEGIN heph-generated (managed by `heph tool gen-gitignore` — do not edit)
/fmt/generated.txt
# END heph-generated

The command is:

  • Scoped. Only copy outputs are listed — in_place rewrites 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 commitcopy
Format source files (gofmt-style)in_place
Run a codemod or auto-fixing linter over checked-in codein_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.