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 read

We 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.

Incrementalist - a Git-based incremental build and testing platform for .NET and .NET Core.

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:

  1. Invokes the run verb - which means we’re going to execute a dotnet command against all of the matching projects (C# or F#);
  2. Uses the --config parameter to load a non-default Incrementalist configuration file; and
  3. Then executes everything after the -- as a dotnet [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:

  1. Load YourApp.sln;
  2. Determine which projects have modified files; and
  3. Compute all of the dependencies of modified projects; and
  4. 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:

  1. Changes to B.csproj - this will trigger both B.csproj and B.Tests.csproj to have their dotnet command executed; no commands for A.csproj or A.Tests.csproj will be carried out.

  2. Changes to A.csproj - Incrementalist will quickly determine that every other project in the solution has either a direct dependency on A.csproj or a transitive dependency on it. This will result in a full solution build.

  3. 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:

  1. The CSV build graphs detected by Incrementalist (and filtered by the globs) will be emitted to bin/output/incrementalist.txt regardless of whether the dotnet tasks succeed or fail.
  2. We will give each dotnet invocation up to 20 minutes to succeed before we cancel it.
  3. We have disabled assembly parallelization via "runInParallel": false.
  4. 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.
  5. We will only execute dotnet commands on projects that match the target 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:

Incrementalist `--dryrun` results on a large 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

  1. NBench is still used in some of our projects, but we’ve largely stopped using it in favor of BenchmarkDotNet these days. 

  2. Your repository’s trunk branch might be called master or main. Akka.NET’s projects all use dev

If you liked this post, you can share it with your followers or follow us on Twitter!
Written by Aaron Stannard on April 18, 2025

 

 

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.