Out-of-Tree Builds in Embedded C++, Done for Real

Last updated:

Plenty of embedded C++ codebases claim to support out-of-tree builds, and plenty of them don’t — not really. The gap matters because “we build out of tree” is usually said to reassure you that the build is clean and self-contained, and when it isn’t actually true, you inherit the exact problem the claim was supposed to rule out: a build that looks tidy but still needs babysitting, one stray generated file at a time. For a team putting AI coding agents to work, that’s not a cosmetic issue. It’s the difference between a broken build state you recover from with a single command and one you recover from by hand-hunting through your own source tree while an agent waits — a slow, recurring cost paid at the worst possible moment, mid-task. This post is the specific, falsifiable version of a foundation I’ve argued for more broadly in Reproducible Embedded Builds, or Becoming the Agent’s Compiling Slave: it takes one property of a reproducible build — that the build never touches the source — and shows how to actually deliver it and how to prove you have.

What is an out-of-tree build?

An out-of-tree build (also called an out-of-source build) is one where the act of building never modifies the source tree — no generated file, no object file, no intermediate artifact ever lands inside a directory that’s part of the source. Everything the build produces lives in a separate build directory, and that directory could be deleted and recreated at any point without touching a single tracked file. That’s the whole definition, and it’s stricter than it first sounds: anything less isn’t out-of-tree, it’s an in-tree build with a build folder bolted on the side. The test later in this post is what tells the two apart.

The half-measures that look out-of-tree and aren’t

Before the fix, it’s worth being precise about the failures, because most codebases that fall short fall short in one of a small number of recognizable ways — and each one looks like out-of-tree support at a glance.

The first is a build/ directory inside the source tree, kept clean only by .gitignore — the file, meaning the list of paths git is told to ignore. That’s fine for keeping the build output out of version control, but .gitignore does nothing to stop a script, a code generator, or a careless build step from writing into a source directory anyway. It hides mess from git; it doesn’t prevent mess. The second is generated files written next to their source “for convenience” — a build that lives elsewhere except for one header or config file it drops beside the source file it came from. The rest of the build being clean doesn’t matter; that one file means the source tree is no longer pristine. The third is a build that’s only clean the first time — the initial run looks perfectly out-of-tree, but the second run reads or writes state the first run left behind, so “reproducible” quietly depends on starting from a state you can no longer get back to.

None of these keep the source tree pristine, and keeping the source tree pristine is the only thing that actually matters here — it’s the property everything else in this post depends on. The payoff of naming the half-measures precisely is that you can look at your own build and say which one you have, instead of assuming “we have a build/ folder” means you’re done.

Why this matters specifically for agents

The reason to care isn’t neatness for its own sake — it’s what a pristine source tree lets you do when things go wrong, especially with more than one worker on the code. When more than one agent, or an agent and you, work against the same source from separate copies, a genuinely out-of-tree build means each one can build independently without fighting over shared generated state that leaked into the source. And when a build goes wrong — a corrupt cache, a half-finished generation step, an inexplicable error — the fix is trivial: delete the build directory and rebuild. That recovery move is only real if the build directory is where all the fallout lives. If the source tree absorbed any of it, there’s no clean directory left to delete — just a source tree you now have to go hunt through by hand, which is exactly the manual, recurring intervention an agent-ready setup exists to eliminate. The payoff, in one line worth quoting: a genuinely out-of-tree build turns “recover from a broken build” from a manual investigation into a single reliable command — and that’s what keeps a shared tree workable for agents and humans at the same time.

The test: delete the build directory and rebuild

Here’s the part that makes “out of tree” stop being a claim and start being something you can check, because a property you can’t test is a property you can’t trust an agent to rely on. The test is falsifiable and takes about a minute. You delete the entire build directory, rebuild from scratch, and confirm two things: the result is identical and working, and git status — the command that reports which tracked files have changed — shows no change whatsoever in the source tree. Concretely, for a typical CMake project — CMake being the widely used build-system generator for C++ — a genuine out-of-source build and its test look like this. The point isn’t the exact commands; it’s that the build directory is somewhere you can delete wholesale and the source tree doesn’t notice:

# Configure and build entirely inside ./build — nothing generated into the source.
cmake -S . -B build -G Ninja
cmake --build build

# The falsifiable test: blow the build dir away, rebuild, and check the tree.
rm -rf build
cmake -S . -B build -G Ninja && cmake --build build
git status --porcelain    # must print NOTHING

That last line is the whole test. If git status --porcelain — the quiet, script-friendly form that prints one line per changed file and nothing at all when the tree is clean — prints even a single line, your build wrote into the source tree, and it isn’t out-of-tree yet. If either check fails — a changed file, or a build that only reproduces the second time — the build isn’t out of tree, and I’d fix that before anything else, because every compile-time check and test harness you build on top of it inherits the same fragility until it’s solid. The payoff is a definition of “done” nobody can argue with: the tree is clean after a delete-and- rebuild, or it isn’t, and the command tells you which.

How builds leak into the tree, and what I’d do instead

Knowing the test, the useful question is why builds fail it, because the causes are a short list and each has a clear fix. The most common cause is in-source configuration — running the build system so that it generates its files into the source directory instead of a separate one. With CMake specifically, the classic mistake is configuring in-source (generating build files right where the source lives) instead of out-of-source (into a separate build/ directory), and the fix is as simple as always using the -B build form above and never the in-source invocation. The next cause is code generation that writes beside its input — a step that generates a header or a lookup table and, for convenience, drops it next to the source file it was derived from. The fix is to point every generator at a path inside the build directory and let the compiler find it there via an include path, rather than materializing it in the source tree. The third is stateful build steps — anything that caches into or reads from the source tree between runs, which is what makes a build clean only the first time.

There’s a tempting shortcut here, and I want to name it so I can reject it: just adding every generated path to .gitignore and calling it done. That makes git status clean, so it even appears to pass the test above — but it passes for the wrong reason. It hides the leak from git rather than stopping it, so the artifacts are still sitting in your source tree, still shared between workers, still there to be picked up stale by the next build. I don’t ship that, because it defeats the one property the whole exercise is for: I want the source tree to actually be pristine, not to merely look pristine to git. The honest version — generate into the build directory, configure out-of-source, keep build steps stateless — costs a little more up front and passes the test for the right reason. The payoff is that the delete-and-rebuild recovery move keeps working forever, instead of working until the first generated file quietly slips back into the tree.

Where this leaves you

“We support out-of-tree builds” is one of those claims that’s cheap to say and, until you test it, easy to believe about your own codebase. The test is a minute of work and it’s decisive: delete the build directory, rebuild, and watch git status. Clean tree, identical result — you’re out of tree, and your recovery move is real. A changed file — you’re not, and the tidy delete-and-rebuild you were counting on is a hope, not a guarantee. Fix it once, at the foundation, and every agent and human sharing that tree gets a build they can reset with one command; leave it, and you’ve kept a manual cleanup step exactly where it does the most damage, in the middle of everyone’s work.

Frequently asked questions

What is an out-of-tree build?

An out-of-tree build (or out-of-source build) is one where building never modifies the source tree: no generated file, object file, or intermediate artifact ever lands inside a source directory. Everything the build produces lives in a separate build directory that can be deleted and recreated without touching a single tracked file — anything less is an in-tree build with a build folder bolted on.

How do I know if my build is really out of tree?

Run one test: delete the entire build directory, rebuild from scratch, and check git status in the source tree. If the rebuild produces an identical working result and git status reports zero changed files, the build is genuinely out of tree; if a single file changed, or the build only reproduces on the second run, it isn't yet.

Isn't a .gitignore'd build/ directory good enough?

No — .gitignore keeps generated files out of version control, but it does nothing to stop a generator or build step from writing into a source directory in the first place. It can even make the delete-and-rebuild test appear to pass while artifacts still sit in your source tree, shared between workers and available to be read stale; the goal is a source tree that is actually pristine, not one that only looks pristine to git.

Why does an out-of-tree build matter for AI agents?

When several agents, or an agent and a human, share a source tree from separate copies, a genuinely out-of-tree build lets each build independently without fighting over generated state that leaked into the source. It also makes recovery from a broken build a single command — delete the build directory and rebuild — instead of a manual hunt through the source, which is exactly the recurring, mid-task interruption an agent-ready build exists to remove.

How do I fix a build that writes into the source tree?

Configure out-of-source so the build system generates into a separate directory, point every code generator at a path inside the build directory rather than beside its input, and remove any step that caches into or reads from the source tree between runs. Then re-run the delete-and-rebuild test to confirm git status comes back clean for the right reason — because nothing leaked, not because .gitignore hid it.