7 min · 1,494 words
Format-on-save, after the editor
Editors format a file when a person saves it. Generators, shell commands and agents do not. I built onwrite around the harder question: how to repair that without putting the file at risk.
An editor knows when a file has been saved. The command line only knows that bytes moved.
That distinction did not matter much when most source code passed through an editor. It matters now. Generators write clients. Migration commands write models. Shell scripts rewrite configuration. Agents use file tools, heredocs and one-line Python programs. The repository still has a formatter, but nothing asks it to run when it would be most useful.
So it runs later. A hook refuses the commit. CI refuses the branch. The agent finds the problem after two more tool calls and formats a file it now has to read again.
I built onwrite to restore that missing boundary.
$ onwrite run -- ./scripts/codegen.sh
onwrite: formatted src/client.ts (eslint, prettier)
onwrite: formatted api/models.py (ruff-check, ruff-format)
It wraps a command, records what changed while the command ran, and applies the repository’s own
fixers and formatters after it exits. There is also a watcher for writes that cannot be wrapped, a
one-shot fix, and a doctor command that explains every tool it selected.
The command is simple. Making it safe to leave running against a source tree was the project.
Never give the formatter the file
Watching a directory and invoking Prettier is a short program. It is also a program I would not trust with my work.
A file write is not necessarily atomic. A watcher can fire in the middle of a heredoc and read half a file. A formatter can hang, crash, emit nothing, or rewrite the file before the next formatter in the chain fails. Its own write produces another filesystem event and starts the loop again.
onwrite follows one rule: a formatter is never handed the real source file.
The file’s contents go to the tool over stdin and return over stdout. Fixers and formatters run as an in-memory chain. The source file is replaced once, after the required steps have succeeded.
source bytes
↓
fixer
↓
formatter
↓
validate output
↓
fsync a temporary file and rename once
Consider a TypeScript file caught halfway through a heredoc. Prettier will normally refuse to parse
it. onwrite keeps that refusal as the result and does not write anything back. If a tool exits
successfully but returns an empty response, that is treated as an error rather than permission to
empty a non-empty file.
When there is valid output, onwrite creates a temporary file beside the target, writes and syncs it,
applies the old permission bits, and renames it over the target. Keeping the temporary file in the
same directory keeps the rename on one filesystem. A reader sees the complete old file or the complete
new file, not the space between them.
The refusal list is longer than the success path. Symlinks are skipped because replacing one would
turn it into a regular file. Binary files, empty files, non-regular files and files over the size
limit are skipped. Tool output is bounded. Tool execution has a deadline. On Unix, the whole process
group is terminated, since the executable in node_modules/.bin may only be a wrapper for the process
still holding the pipe open.
A formatting loop is an identity problem
Every format-on-write watcher reaches the same four events:
- a process writes a file;
- the formatter rewrites it;
- the watcher sees the formatter’s write;
- the formatter runs again.
The common escape is a timer. Ignore events for a few hundred milliseconds after formatting and the loop stops. A real edit inside those few hundred milliseconds disappears with it.
onwrite records the exact bytes it wrote. A later event is ignored only if the file still has those
bytes. An actual edit has a different digest even when it arrives immediately, so it goes through the
formatter normally.
The tests for this are intentionally literal. One asserts that a formatter is spawned exactly once for its own write. Another makes a genuine edit close to that write and asserts that the edit is not swallowed. A timing window can pass the first test. It cannot reliably pass both.
Read the policy that is already there
onwrite does not ship a style guide and does not install tools.
A .prettierrc is evidence that Prettier belongs in the chain. A Ruff table in pyproject.toml is
evidence for Ruff. .clang-format, Cargo.toml, build.zig and Terraform files carry the same kind
of evidence for their tools. The absence of a marker is a decision too. A global Prettier binary is
not enough reason to rewrite a project that never selected Prettier.
Repository-local binaries are resolved before PATH. A JavaScript project gets the Prettier version
in node_modules/.bin; a Python project gets the Ruff executable in its virtual environment.
Competing formatters share an exclusion group, so a Biome project does not also pass through Prettier.
Fixers run before formatters, giving the formatter the final say on layout.
Zero configuration here means reusing configuration rather than inventing defaults.
There will be repositories the detector cannot infer. .onwrite.json can disable a built-in, add
exclusions, or describe a formatter the binary has never heard of. The built-in registry uses the same
data structure as user configuration: extensions, command, arguments, markers, execution mode and
exclusion group. Supporting a new formatter should be a registry entry unless the formatter has found
a genuinely new way to behave.
onwrite doctor prints the evidence behind every selection. I added it early because silent inference
becomes superstition as soon as it is wrong.
What changed during one command
The watch command is the obvious interface, but run is the one I expect to use most:
onwrite run -- python manage.py makemigrations
onwrite run -- ./scripts/codegen.sh
onwrite run -- agent-command
It takes snapshots around the child process and scopes formatting to the files changed while that process was alive. There is no daemon to stop afterwards, and no partial-write race to tune around because inspection begins after the command exits.
Git does not answer the same question. A dirty working tree tells you what differs from a commit, not
what this invocation wrote. The repository may already contain unrelated edits. onwrite run is
interested in the causal boundary around one process, not the state of the branch.
Standard input, output, error and the exit code pass through to the child. Wrapping a command should not change how the command behaves, apart from the source files it leaves behind.
The stale copy in the agent
An automatic formatter creates a problem that is easy to miss when the writer is a model.
An agent writes a file and retains that text in context. The formatter changes imports, quotes and layout behind it. The version on disk is now correct, but the agent’s version no longer exists. Its next edit may be calculated against stale text.
The Claude Code hook reports the files it changed and asks the agent to read them again:
onwrite reformatted these files after your edit. Their contents on disk now
differ from what you wrote, so re-read them before editing again:
- src/config.ts (eslint, prettier)
Formatter failures are returned too. A parser refusing a file immediately after generation is useful evidence that the file is incomplete. Hiding that result would preserve a clean transcript at the cost of the next several steps.
The hook translates one agent’s event format. It is not the centre of the program. Anything that can
be launched from a shell can sit behind onwrite run --, and the watcher covers tools whose process
cannot be wrapped.
What the first release proves
The repository contains about 6,200 lines of Go and 87 tests. The unit suite uses fake tools for
failures that are hard to ask of a real formatter on demand: hanging, crashing, returning empty output
and leaking child processes. Integration tests run the actual command-line interfaces of Prettier,
ESLint, Biome, Ruff, Black, goimports and gofmt.
CI runs vet, formatting checks, race-enabled unit tests and those real-tool integration tests on Linux and macOS. It also cross-compiles every release target. I ran the same Go test, vet and race suites locally before publishing this note.
The first release is v0.1.0, with binaries
for macOS, Linux and Windows. The tests establish that the implementation behaves as designed. They do
not establish that the detector has met every strange monorepo, wrapper script or formatter
configuration in use. Only other people’s repositories can do that part.
Install it with Go:
go install github.com/amartya-dev/onwrite/cmd/onwrite@latest
The repository lives at hunr-ai/onwrite; the module path is still the one in go.mod, so that is
the path go install needs until the module is renamed and re-tagged.
Then let it explain itself before letting it write:
onwrite doctor
onwrite fix --all --dry-run
The code is MIT licensed at github.com/hunr-ai/onwrite.
Format-on-save was a well-placed boundary between producing source and trusting it. The editor used to be the only place that needed the boundary. It is not anymore.