Introducing Incrementalist, an Incremental .NET Build Tool for Large Solutions
Reduced Akka.NET's average build time from ~1.25hrs to 15 minutes.
12 minutes to readWe blog a ton about Akka.NET here, but Petabridge really is a .NET open source company. Throughout our work on Akka.NET we create many other OSS tools in support of much more general purpose .NET use cases, such as the .NET Runtime Dashboards we touted on our YouTube channel a couple of weeks ago or, back in the day, tools like NBench1.
Today though we’re writing about a brand new tool we’ve been working on for the past several months: Incrementalist v1.0, a command-line tool that leverages git and Roslyn solution analysis to drastically reduce build times for large .NET solutions and monorepos.
We’ve been using older versions of Incrementalist in production inside the Akka.NET build pipeline since 2019 - it cuts our average pull request build time down from about 1 hour and 15 minutes to ~15 minutes. Those older versions of Incrementalist just spat out the smallest possible build graphs as a .csv
file - it was up to you to parse it and use the data accordingly.
Incrementalist v1.0 is a totally different animal: it runs the dotnet
commands for you.
For example, from Akka.NET’s live build system:
dotnet incrementalist run --config .incrementalist/testsOnly.json -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults
This call:
- Invokes the
run
verb - which means we’re going to execute adotnet
command against all of the matching projects (C# or F#); - Uses the
--config
parameter to load a non-default Incrementalist configuration file; and - Then executes everything after the
--
as adotnet [verb] [impactedProjectFile.*proj]
Let’s dive in and learn more.
Installation
You can install Incrementalist.Cmd either as a global or local dotnet tool
:
Global Tool
dotnet tool install --global Incrementalist.Cmd --version 1.0.0
This will make incrementalist
available everywhere for the current user on this machine.
Local Tool
I personally prefer local dotnet tool
installations because they tend to be more CI/CD and Dependabot-friendly:
dotnet new tool-manifest # if you haven't already created a .config/dotnet-tools.json
dotnet tool install Incrementalist.Cmd
This will create a .config/dotnet-tools.json
at the root of your repository, which will look like:
{
"version": 1,
"isRoot": true,
"tools": {
"incrementalist.cmd": {
"version": "1.0.0",
"commands": [
"incrementalist"
],
"rollForward": false
}
}
}
And just as a “FYI” - I will almost always set rollForward
to true
because that will allow this tool to automatically run on newer versions of the .NET runtime without needing to be updated by the vendor first.
How It Works
We have a fairly detailed “How Incrementalist Works” page in the Incrementalist documentation, but just to save you a click - it works like this:
flowchart TD
A[Your Git Changes] --> B[Incrementalist]
B --> C{Analyze Changes}
C -->|Solution-wide changes| D[Build All Projects]
C -->|Specific changes| E[Build Affected Projects]
style B fill:#f9f,stroke:#333,stroke-width:4px
When Incrementalist launches, it’ll first consider the verb you’re using: create-config
, list-affected-folders
, or most popularly: run
. We’ll focus on the run
command because that’s the real benefit of this tool.
Suppose you have two git branches, using Akka.NET as an example:
dev
- this is our “trunk” that all pull requests get merged into2.feature/{name}
- this is a feature branch that you are currently doing some work on.
Incrementalist’s first job is to determine which files have been affected between feature/{name}
compared to dev
- and we specify the base branch name either on the dotnet incrementalist
command line or in a configuration file.
So imagine you have the following solution structure in your project:
root
├── src
│ ├── A
│ │ └── A.csproj
│ ├── B
│ │ ├── B.csproj ← Depends on A
│ ├── Directory.Build.props
│ └── Directory.Packages.props
├── tests
│ ├── A.Tests
│ │ └── A.Tests.csproj
│ └── B.Tests
│ └── B.Tests.csproj
├── YourApp.sln
└── global.json
You modify some files in B.Tests.csproj
- that’s going to show up on the git diff
performed by Incrementalist. Simple enough.
The next stage of analysis is where Roslyn enters the picture - Incrementalist is going to:
- Load
YourApp.sln
; - Determine which projects have modified files; and
- Compute all of the dependencies of modified projects; and
- Construct the smallest set of projects to build possible given the dependencies and other rules specified in the Incrementalist configuration file or CLI arguments.
In this case, B.Tests.csproj
is the only project that would be run.
However, consider the following scenarios:
-
Changes to
B.csproj
- this will trigger bothB.csproj
andB.Tests.csproj
to have theirdotnet
command executed; no commands forA.csproj
orA.Tests.csproj
will be carried out. -
Changes to
A.csproj
- Incrementalist will quickly determine that every other project in the solution has either a direct dependency onA.csproj
or a transitive dependency on it. This will result in a full solution build. -
Changes to a “solution-wide” file:
YourApp.sln
itself,global.json
,Directory.Build.props
,Directory.Packages.props
, or any other files imported by a solution-wide file (such as a custom.props
or.targets
file) will trigger a solution-wide build.
Incrementalist works from the impacted files to determine the impact they have on the projects. It does not consider code-level changes: “Assembly.Class.Method
was updated so only test all objects that might transitively be impacted by this” - we don’t bother with that because it’s error-prone and tremendously expensive to compute. Instead, Incrementalist sticks with project-level dependencies.
Advanced Configuration and Use Cases
Configuration Files
Incrementalist supports configuration files and by default will use one if .incrementalist/incrementalist.json
exists.
If you want to generate a configuration file for your own use, we have a built-in verb to do just that:
dotnet incrementalist create-config -b dev --verbose --parallel
This will create or overwrite the configuration file at .incrementalist/incrementalist.json
with the following content:
{
"gitBranch": "dev",
"verbose": true,
"runInParallel": true
}
Incrementalist configuration values can be overwritten by CLI arguments.
If a configuration value is specified both in the file and on the CLI, the CLI always wins.
If you want to create a custom configuration file, you can specify a file path for it here:
dotnet incrementalist create-config -b otherBranch --verbose --parallel --config .incrementalist/customConfig.json
Globbing and Output Files
Incrementalist also supports filtering build results - we have to use this in the Akka.NET project because none of our tests that depend on Testcontainers can run on Windows build agents due to lack of Linux guest OS Docker support.
{
"outputFile": "bin/output/incrementalist.txt",
"gitBranch": "dev",
"verbose": false,
"timeoutMinutes": 20,
"continueOnError": true,
"runInParallel": false,
"failOnNoProjects": false,
"skip": [
"**/.Tests.MultiNode.csproj",
"src/examples/**"
],
"target": [
"**/*.Tests.csproj",
"**/**/Akka.Streams.Tests.TCK.csproj",
"**/.Tests.fsproj"
]
}
What this configuration does:
- The CSV build graphs detected by Incrementalist (and filtered by the globs) will be emitted to
bin/output/incrementalist.txt
regardless of whether thedotnet
tasks succeed or fail. - We will give each
dotnet
invocation up to 20 minutes to succeed before we cancel it. - We have disabled assembly parallelization via
"runInParallel": false
. - We will always exclude any projects that match the
skip
glob criteria, even if they have changes AND even if Incrementalist has recommended a solution-wide build. - We will only execute
dotnet
commands on projects that match thetarget
criteria - only one of these criteria has to match in order for a project to be included.
You can then tell Incrementalist to run this custom configuration inside your build pipelines:
dotnet incrementalist run --config .incrementalist/customConfig.json -- test -c Release
Dry Runs and Previewing Execution
One thing I absolutely loathe is testing .yaml
files for Azure DevOps and GitHub Actions. It’s one of the worst possible feedback loops in software development: waiting to find out what your CI/CD system is going to actually do with a given set of changes.
Therefore, I like being able to preview things locally and quickly. Therefore, the --dry
parameter on the incrementalist run
command will help us do that:
dotnet incrementalist run --config .incrementalist/testsOnly.json -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults
Let’s see what this does on a fairly beefy (in terms of project impact) Akka.NET pull request:
Click here for a full-sized image
We can see that Incrementalist determined that 69 projects would be impacted, and it listed them all, but then after glob filtering was applied we’d only be running against 24 of them. It took Incrementalist about 40 seconds to do this on my computer, which is not bad considering that it has to analyze the ~100 project solution without the benefit of IDE caching.
Conclusion
So there you have it. If you liked this post, give Incrementalist a star on GitHub and then take it for a spin in your own projects! Let us know if there’s anything we can do to improve it for you and your use cases too.
And if you want to see some more real-world examples of where Incrementalist is used, we’ve added some of those to the documentation here: “Real-World Uses of Incrementalist”
-
NBench is still used in some of our projects, but we’ve largely stopped using it in favor of BenchmarkDotNet these days. ↩
-
Your repository’s trunk branch might be called
master
ormain
. Akka.NET’s projects all usedev
. ↩
- Read more about:
- Akka.NET
- Case Studies
- Videos
Observe and Monitor Your Akka.NET Applications with Phobos
Did you know that Phobos can automatically instrument your Akka.NET applications with OpenTelemetry?
Click here to learn more.