Loading

The build integration

<PackageReference Include="curb" Version="*" PrivateAssets="all" />
		

That is the whole setup. From then on dotnet build formats the project's source before compiling it.

The package is build-only — netstandard2.0, no library, DevelopmentDependency, nothing added to your output. Put it in Directory.Build.props to apply it across a solution.

The target is BeforeTargets="CoreCompile", and the ordering is the entire point. From the targets file:

Curb runs before the compiler, not after it.

The point of this ordering is EnforceCodeStyleInBuild. With that property set, the IDE analysers run inside CoreCompile and report IDE0055 and the code style rules as build diagnostics. A target at BeforeTargets="CoreCompile" has already rewritten the files by then, so the compiler reads formatted source and the only style diagnostics left are the ones that genuinely need a compilation to decide. Someone — or something — building the project gets the mechanical offences fixed underneath them and only has to think about the semantic remainder.

Running after the build, or as a separate command someone has to remember, would not do that.

Running after the compiler would still report every mechanical offence as a diagnostic first, which is precisely the cost this is meant to remove. Running as a separate command means someone has to remember, and eventually someone does not.

The default depends on configuration, because the two situations want opposite behaviour: a developer wants their file fixed, and a release or CI build wants to be told, not edited.

Configuration Default behaviour
Debug (and anything not Release) rewrite the file
Release check only; fail the build if anything would change

Override with Curb_Check when you want the other one.

dotnet build -c Release -p:Curb_Check=false
dotnet build -p:Curb_Check=true
		
  1. rewrite even in Release
  2. check without rewriting
Property Default What it does
Curb_Check true in Release, else false Check instead of rewriting. A check that finds unformatted files raises CURB0001.
Curb_Bypass false Skip Curb entirely — no process start, no stamp file, nothing. The single escape hatch for a build that must not be touched.
Curb_UnformattedAsWarnings false Report CURB0001 as a warning instead of an error. Off by default, because a check that does not fail the build is a check nobody notices.
Curb_LogLevel low MSBuild message importance for Curb's own output: high, normal or low. Errors and warnings are raised as diagnostics regardless.
Curb_Exe unset Path to a native Curb binary. Roughly a hundred times faster to start than the framework-dependent build the package carries.
Curb_Dll the bundled CLI The framework-dependent build shipped in the package, run on the SDK doing the build.
Curb_StampFile $(IntermediateOutputPath)curb.stamp Incrementality stamp.
Curb_Cache true Reuse the previous run's verdict for files that have not changed. Set false to build without a cache.
Curb_CacheFile $(IntermediateOutputPath)curb.cache Where that cache lives.
Curb_FileList $(IntermediateOutputPath)curb.files The compile set handed to the CLI.
Curb_UnformattedFile $(IntermediateOutputPath)curb.unformatted The paths check reports back as unformatted, one per line — what the target reads to attach CURB0001 to each of them.
Code Severity Meaning
CURB0001 error, or warning with Curb_UnformattedAsWarnings A file is not formatted. Only check can produce this, and it is raised once per unformatted file, attached to that file — not to the project — so a GitHub Actions annotation names the file a reviewer needs to look at.
CURB0002 error, always Curb itself failed. This is an error whatever the warnings setting says — a formatter that could not run has verified nothing, and saying so quietly would be worse than not running at all.

There are two layers, and the first one matters more.

The target declares Inputs="@(Compile);@(EditorConfigFiles);$(MSBuildProjectFullPath)" against an output stamp. When none of those changed, MSBuild skips the target entirely — no process start, no directory walk, no file reads.

The project file is an input because changing it can change which files are compiled. .editorconfig files are inputs because changing one changes the answer for every file they govern.

This is the common case on every build after the first.

Once the target does run — because one file changed — the cache decides how much work Curb does. Without it, a project where one file out of eight hundred changed re-parses the other seven hundred and ninety-nine only to conclude they were already formatted. With it, those files cost a hash comparison rather than a parse.

The cache lives at $(IntermediateOutputPath)curb.cache and is passed to the CLI as --cache. It records, per file, that Curb ran the formatter over exactly those bytes under exactly those resolved options and got them back unchanged. A file whose bytes moved, or whose .editorconfig answer moved, is not in it and gets formatted normally.

It earns the most with Curb_Check=true. A failing check never stamps, so the target re-runs on every build until someone formats the file. With the cache, those re-runs cost one file rather than the whole project.

The cache is in FileWrites, so dotnet clean removes it along with the stamp.

Two things it does not do. It never records a file Curb just rewrote — only one it was watched to leave alone — so an idempotency bug still shows up as a file that keeps changing. And it does not skip reading source: the key is the file's content, so every file is still read, just not parsed.

Curb has no ambient cache under a user profile directory. The caller names the path or there is no cache — one nobody named is one nobody can find, clear, or reason about.

The package bundles a framework-dependent build of the CLI rather than five native binaries, because carrying all of them would be roughly 60 MB of which any given machine needs a fifth.

If the per-build process start matters to you — in a large solution it can — install the native tool and point at it:

<PropertyGroup>
  <Curb_Exe>$(HOME)/.dotnet/tools/curb</Curb_Exe>
</PropertyGroup>
		
dotnet build -p:Curb_Bypass=true
		

One property, honoured everywhere, so a build that must not be touched has a single thing to set. Curb also skips design-time builds and restore-only invocations automatically, so your IDE is never fighting you as you type.