# How to preview a regeneration as a diff before writing files

> See exactly what a generator would change before it changes anything, with a dry run that reports creates, replacements, and the files its mode protects.

Source: https://voxgig.com/howto/preview-a-regeneration-as-a-diff

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Run the generator in dry-run mode and read the report before you let it write. It should name every file it would create, every file it would replace with a diff of the change, and every file its mode protects. A dry run that writes nothing can be repeated, which is what lets you compare two runs and see that only the intended files move.

---
## You will need

Node 22 or later, and a generator that writes into a project you have edited. The file modes used
here follow what jostraca calls write, preserve, and merge, and most generators offer something
equivalent under another name. The idea is borrowed from infrastructure tools, where [a plan you
read before applying](https://developer.hashicorp.com/terraform/cli/commands/plan) has been the
default for years.[^1]

Voxgig maintains sdkgen and jostraca. This page compares their dry run with OpenAPI Generator and
with generating into a temporary directory.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A built-in dry run](https://jostraca.org/) | The generator has one, and it reports modes as well as changes | Trust in the generator's own report, which you cannot check without running it | The generator has no such mode |
| [OpenAPI Generator into a temp directory](https://openapi-generator.tech/docs/usage#generate) | Any generator, because it needs nothing from the tool beyond an output path | A second copy of the output, and a comparison you drive yourself | The generator merges into existing files rather than replacing them |
| [generate then git diff](https://git-scm.com/docs/git-diff) | You are already in a clean tree and can revert cheaply | The files are written before you look, so a protected file may already be gone | Someone has uncommitted work in the same tree |
| [jostraca file modes](https://github.com/jostraca/jostraca) | You want the protection declared per file rather than remembered per run | Modes to set, and a merge that can still conflict on a hand-edited file | Nobody edits the generated tree |

The real difference is when you find out. Writing first and reading the diff afterwards works
perfectly in a clean tree, and it is the common case. It fails on the day somebody has uncommitted
work, or the generated tree is not in version control, or a file the generator replaces was never
committed in the first place.

A dry run also gives you something a diff after the fact does not: the generator's own reasoning. A
file kept because its mode says preserve is a different fact from a file that happens to be
unchanged, and only the tool can tell you which one applies. That distinction is what stops a team
from deleting a mode they think is doing nothing.

## Report the mode, not only the change

Three outcomes per file, and the third is the one people forget.

```js title="generate.mjs"
if (file.mode === 'preserve') {
  report.push({ path: file.path, action: 'keep', why: 'mode is preserve' })
  continue
}
if (before === file.content) {
  report.push({ path: file.path, action: 'same' })
  continue
}
report.push({ path: file.path, action: 'replace', diff: diff(before, file.content, file.path) })
```

The words `keep` and `same` look identical in a working tree and they mean opposite things. A file
that is the same would be overwritten harmlessly on the next run. A file that is kept would be
overwritten destructively, and is not, because somebody set a mode.

Include the diff in the report rather than only a count. Two hundred changed files is a number; two
hundred diffs where 195 are a changed generator banner is a decision you can make in a minute.

## Make the dry run provably inert

The strongest property of a preview is that running it changes nothing.[^2]

```js title="demo.mjs"
console.log('dry run against the checked-in project')
show(plan('project'))
```

The same call with `apply` set is the only path that writes. One code path for both, with writing
behind a flag, is what stops the preview and the real run from drifting apart. A preview that lies
is worse than no preview, because it is believed.

Test that property directly. A test that runs the plan twice and asserts the same report is a test
that the preview wrote nothing, and it will fail the day somebody adds a cache file.

## Check it worked

Run the preview against a project that carries a hand edit.

```bash
node demo.mjs
```

```text output
dry run against the checked-in project
client.js    replace
  --- client.js
  +++ client.js
  -export function listMeters(cursor) {
  -  return request('GET', '/meters', { cursor })
  +export function listMeters(cursor, limit) {
  +  return request('GET', '/meters', { cursor, limit })
   }
   
  +export function loadMeter(id) {
  +  return request('GET', `/meters/${id}`)
  +}
  +
config.js    keep (mode is preserve)
README.md    create
```

Three files, three outcomes. The client gains a parameter and an operation, which is the change the
spec asked for. The configuration file carries a hand-edited timeout and is kept, with the reason
stated. The README does not exist yet, so it is a create rather than a diff nobody needs to read.

```bash
node --test generate.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 122.73695
```

## When it goes wrong

The preview and the real run disagree. Two code paths. Make the writing a flag on the same function,
and test that a dry run leaves the tree byte-identical.

Every file shows as replaced. Line endings or a trailing newline differ between the generator and
whatever last wrote the files. Normalize before comparing, or the report is noise. The same problem
shows up in [kubectl
diff](https://kubernetes.io/docs/reference/generated/kubectl/kubectl-commands#diff), and the fix
there is the same: compare the values, not the bytes.

A protected file was overwritten anyway. The mode was set on a path that no longer matches, usually
after a rename. Fail the run when a mode names a file the plan does not produce.

The diff is unreadable. The generator reorders keys or regenerates a timestamp. Sort deterministic
output and keep timestamps out of generated files entirely.

## When not to do this

Do not use a dry run instead of version control. A preview tells you what one run would do, and
history tells you what every run has done. Commit the generated tree, or accept that you cannot
answer questions about last month.

Do not let a preview become the review. Reading 240 diffs on a terminal is not a review, and a
generator that produces 240 diffs for a one-field change has a formatting problem worth fixing
first.

Do not rely on sdkgen's dry run to protect a file you have not given a mode. The report tells you
what the configured modes would do, and a file nobody protected is a file the next run replaces.

## Related how-tos

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: Terraform reports the outcome of a plan through its exit status if asked. With
`-detailed-exitcode`, [the command](https://developer.hashicorp.com/terraform/cli/commands/plan)
exits 0 for an empty diff, 1 for an error, and 2 when there are changes.
[`kubectl diff`](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_diff/) exits 0 for
no differences, 1 for differences, and any larger number for an error, and expects any replacement
diff program to follow the same convention. Two tools agree that a change deserves an exit code of
its own, and disagree about which one.

[^2]: GNU make's version of the idea has four names.
[The manual](https://www.gnu.org/software/make/manual/html_node/Instead-of-Execution.html) lists
`-n`, `--just-print`, `--dry-run`, and `--recon` for one flag, describes it as a "No-op" and adds
that some recipes are executed anyway. A dry run with an exception clause is the usual kind, and
this one carries its exception in the same paragraph as its promise.