Skip to main content
Forge writes a lot of code, and the arrangement only works if you and forge can always tell which files are whose. Project anatomy gives the three groups: regenerated files, files written once and then yours, and files that are purely yours. This page explains the machinery behind that table: how a file declares its owner, what forge generate will and will not touch, how a hand edit is caught, why generated files merge cleanly in plain git, and the commands for changing ownership on purpose. The design rule underneath is that forge never silently destroys your work and never silently lets generated code drift. Every ambiguous case stops and tells you, naming the file.

Reading the header

Every file forge regenerates starts with a banner:
The first line is the standard marker Go tooling and editors recognize. The forge:hash line is the file’s own fingerprint: a hash of its contents. The third line says who owns it, and the Source: line says which input it was projected from, so you know where to make the change instead. The same four lines appear in TypeScript (*_gen.ts) and KCL (config_gen.k, with # comments). A file that is yours says so as plainly:
Those files carry no hash at all, because there is nothing to certify: forge does not own the bytes. A few starters sit between the two. The Dockerfile, Taskfile.yml, .golangci.yml and the CI workflows carry a marker so forge project upgrade can tell whether you have customized them. Editing them is expected, and it is never reported as drift.

What forge generate will and won’t touch

The command’s behavior follows from the ownership of each file: A few consequences are worth stating directly. Generate never changes a handler body or a migration. It appends new CRUD methods to handlers_crud.go when a new CRUD RPC appears, and never modifies the existing content. It is safe to run any time and with several agents in one checkout: concurrent runs queue on a lock file, .forge/forge.lock, rather than collide. And a run on a tree that is byte-identical to one a full run already verified says nothing to do and stops in seconds, so running it whenever you are unsure costs little.

When a generated file is edited

Suppose you add a line to internal/db/customer_orm_gen.go. forge lint --generated-drift catches it before generate does:
The same drift probe feeds forge generate, forge lint, forge project audit and forge ci verify-generated, so a finding never gates one command and not another. forge generate itself runs a guard before it writes: files that fail to verify are compared against what this run would render. If they match, they are quietly re-recorded. If they differ, the run fails with one batched report listing every conflict, and its writes are rolled back, so you learn about all of them at once instead of discovering them one run at a time. You then have three ways out, in order of preference:
  1. Move the change to a file that is yours. Nearly every generated file has an owned seam beside it: handlers_crud.go for CRUD behavior, <entity>_repo_ext.go for queries the generated store cannot express, providers.go for wiring, observe_chain.go for a package’s tracing chain.
  2. Pass --force to throw your edit away and regenerate. This is the explicit “discard my changes” choice, scoped to the files the guard reported.
  3. Disown the file, below.
Because the hash covers the whole body, any edit to it is detected. That is the point: a generated file that quietly differs from its template is the worst kind of drift.

Files that can’t carry a header

JSON has no comments, so a generated JSON file (an alert default, a dashboard, an observability spec) can’t hold its own hash. For those outputs, forge keeps one small certificate file per output under .forge/hashes/, holding the hash of the output’s last render:
The certificate for deploy/observability/alert-defaults_gen.json contains a single line, the sha256 of that file. It is one file per output, never a shared manifest, so a change to one output touches only its own certificate. All of .forge/hashes/ is committed.

The state forge keeps

Everything under .forge/ that is committed is small, per-file and keyed by path: Everything else there (the lock, logs, a failed run’s output) is per-checkout scratch. The birth ledger exists to give “written once” a precise meaning. A file that is absent could mean two different things: forge has never written it, or you deleted it on purpose. Looking only at the disk can’t distinguish them, and an early version that treated “absent” as “not yet written” brought a deleted lifecycle test back three times in one session. The ledger records every path forge has ever scaffolded, so a present entry with a missing file means you deleted it and forge respects that, in this checkout and in every clone, since the ledger is committed.

Generated files merge in plain git

Agents work on branches, many at once. If two branches each add an entity and each run forge generate, the generated files must merge without help. Forge treats this as a design requirement, and it is tested: the project’s own end-to-end suite takes a scaffolded project, changes independent inputs on two branches, regenerates each, merges with plain git merge (no merge driver, no attributes, the way GitHub’s server-side merge does), and then requires that a forge generate on the merged tree changes nothing. Three things make that hold:
  • Sectioned self-certification. A file that aggregates independent parts, such as a package’s mocks (one per interface), the config schema (one block per section) or the database grants, divides itself into sections. Each section has a named header and its own hash on the next line, so a change to one part rewrites only that part’s lines. The mock_gen.go generated for the pricing package shows the shape:
    Two branches that change different interfaces edit different sections and merge cleanly, and the merged file still verifies, because each section’s hash depends on that section alone.
  • One file per part. Outputs that used to be one file listing every entity (the frontend mock dispatch table, the nav and dashboard modules, the database grants) are written one file per entity, so entities born on different branches merge as separate files.
  • Per-file state. The certificates and the birth ledger are one file per path, not a shared manifest, so two branches that each add a service never edit the same line of state.
A merge driver for generated files also ships as a convenience. It merges everything else with git’s normal three-way merge and takes the fingerprint lines from your side, and the scaffolded .gitattributes routes *_gen.go, *_gen.ts and the state files to it. Nothing depends on it: GitHub’s server-side merge and merge queues run no driver, which is exactly why the outputs themselves are designed to merge without one. FORGE_NO_MERGE_DRIVER=1 opts out. A conflict in a generated file usually means the inputs conflicted too, so resolve the proto, migration or config first. Then run forge generate: it renders the merged inputs, and you have no reason to hand-merge the output.

Taking a file over: forge project disown

Sometimes the generated form can’t express what you need. Disowning transfers the file to you for good:
After that, forge never touches the file again: no regeneration, no overwrite, not even with --force, no drift errors. It is a one-way door, and the --reason is required. The reason is recorded per path in .forge/disowned.json, and it is deliberately the payload: a disown means the generator couldn’t do something you needed, which is design feedback forge project audit --json surfaces. Disown is the last resort. Prefer an owned seam when one exists, since a seam keeps working through every regenerate while a disowned file is yours to maintain against future changes in the schema or the generator. To return a disowned file to forge, delete it and run forge generate; the emitter re-emits the pristine render, and your content is discarded, so copy out anything you want to keep first. A second form, --absent, declines a scaffold-once file forge has not written, or one you do not want at all:
The decline is recorded in the birth ledger, and forge generate stops writing it.

Getting a deleted file back: rescaffold and scaffolded

Deleting a scaffold-once file is an act of ownership that forge honors. Two commands let you see and reverse it:
scaffolded reads the birth ledger and marks each path present (your bytes, left alone) or ABSENT (you deleted it and forge is respecting that). On the scratch project used for these pages it listed 146 scaffold-once files, from CI workflows to the frontend’s pages. rescaffold drops a path’s birth record, writes the file as forge would scaffold it for the project today, and records the birth again. The file is yours once more, and deleting it again sticks. It refuses a path that still exists, so it never overwrites your bytes. To replace a file you edited with the current template, use forge project upgrade --force <path>, which shows the diff first.

Where to put a change

When you want forge’s output to behave differently, the answer is almost always in one of these places, in this order:
  1. Change the input. The proto, the migration, config.proto or the KCL. Most “generated code is wrong” cases are an input that says something different from what you meant.
  2. Use the owned file beside it. Each generated file names its sibling: CRUD behavior in handlers_crud.go, custom queries in <entity>_repo_ext.go, construction and wiring in providers.go and compose.go.
  3. Disown the file, with a reason.
  4. Report it. If the generated form is wrong rather than merely different, that is a forge bug. A disown reason is read as design feedback, and forge report-bug is the direct route.
Two sources of truth covers what the inputs are, and Contracts and dependency injection covers compose.go reconciliation in detail. For the lint lanes that guard these boundaries (--generated-drift, --scaffolds, --contract), run forge lint --help.