The code is published as a .NET tool on NuGet.org: https://www.nuget.org/packages/dotnet-bld
dotnet tool install --global dotnet-bld
dotnet bld
bld is a utility for working with .NET/MSBuild project files and solutions. It does rely on the Microsoft Build system (Microsoft.Build and NuGet assemblies) for proper property evaluation of msbuild project files (*.csproj ...). It is intentionally small and focuses on repository hygiene:
- Clean build output safely.
- Inspect cleaning statistics without touching the disk.
- List NuGet package references.
- List and update TFMs (target frameworks of your projects).
- Enable Central Package Management.
- Scan and update outdated NuGet package versions.
- Scan for Docker base image references.
This is especially handy when working with agentic coding tools or large repos where TFMs, CPM, package references, and build outputs can drift.
Note on the target framework: the tool targets
net10.0(<TargetFramework>net10.0</TargetFramework>inbld/bld.csproj) and sets<RollForward>Major</RollForward>, so it also runs on newer runtimes. (Earlier 0.2.x builds multi-targetednet8.0;net10.0;net8.0support was dropped.)
bld leverages the existing dotnet SDK msbuild targets as well as msbuild targets from a Visual Studio installation on the machine, if available. It transparantly locates and uses msbuild assemblies as well as msbuild .targets. It can process project files in current SDK style format as well as old style framework format (it does support processing projects targeting .net Framework even in old project file format). It can also process both recent and old solution file formats (.sln, .slnx, .slnf).
bld fully evaluates project file properties using the Microsoft build system. It never guesses where the build output might go, it evaluates the project files just as a build would. It extracts the actual artifact and intermediate output paths, package directories ...
bld clean is a tool to clean build output folders for (especially .net) MSBuild projects. It can either generate clean scripts (.cmd or .sh) or delete build outputs directly. It is very defensive in what it actually deletes, and never touches any files without explicit consent.
Your output folders can grow big easily. Even with tooling from the dotnet SDK or msbuild itself, it is not always straightforward to clean (outdated) build output or publishing folders. Creating small proof of concept projects, having agentic coding tools create small projects, or even upgrading the target framework of your projects from .net8.0 to .net10.0 can leave you with obsolete build artifacts which are either not straightforward to delete or which msbuild or dotnet clean won't delete anymore.
It cleans build output, publishing and intermediate folders.
Yes, you can use dotnet clean or msbuild /t:clean to clean build output from your solutions ...
However, these tools ... well these
- don't clean old build targets (after migrating from net8.0 to net9.0, net8.0 output doesn't get cleaned)
- don't delete default publishing folders (which can be huge)
- don't delete intermediate build folders (obj)
- dotnet clean can have limitations cleaning older framework-style projects
Yes, you can just use git/source control to nuke anything not under source control
- not all projects are under git/source control
- if the build output isn't below the repo, this doesn't work (dotnet\runtime)
- traverse directories looking for .sln, .slnx, .slnf
- process all configurations from the solution files
- in process evaluation of properties for each project and configuration using the Microsoft build assemblies and target files, just as a build would (note: the Microsoft.Build evaluation is not instant)
- automatically resolves default msbuild install (typically .NET SDK) and resolves VSToolsPath for additional target files provided by Visual Studio installations (if available, not required)
- enables you to delete only non-current build output (TagetFramework(s) no longer referenced in proj file), esp. useful after upgrading projects to a recent target framework
- supports both the classic
bin/<Configuration>/<tfm>[/<rid>]layout and the SDK artifacts layout (UseArtifactsOutput=true,artifacts/bin/<project>/<config>_<tfm>) - cleans publish and pack output (
PublishDir,PackageOutputPath,artifacts/publish,artifacts/package) when--publishis given - validates tfms for .net projects to make sure the correct stuff gets cleaned
- by default doesn't delete, only dumps stats and the command line to delete folders. Nothing gets touched unless you specify --delete
- support for linux
- defensive approach in determining what to delete
- never deletes or changes files without explicit consent
Note:
- global.json ... due to the consistent /s way msbuild, dotnet msbuild, and dotnet build handle global.json ...
dotnet build bld.sln
dotnet run --project bld -- clean --root <root-or-sln>Use --delete only after you have reviewed the generated script or statistics.
| Command | Stability | Purpose |
|---|---|---|
clean |
Stable | Evaluate projects, report disk usage, and emit an OS-specific deletion script (dry-run by default). |
stats |
Stable | Print cleaning statistics only; never writes scripts or deletes files. |
nuget |
Beta | Inspect NuGet dependencies and optionally aggregate package usage. |
tfm |
Beta | Migrate project target frameworks. |
cpm |
Beta | Convert a solution to Central Package Management. |
outdated |
Beta | Check (and optionally update) NuGet packages to newer versions. |
containerize |
Beta | Discover Dockerfiles and projects using SDK container build properties. |
build-props |
Beta | Trace Directory.Build.props files and MSBuild property provenance across projects. |
Commands marked Beta may change behavior, arguments, or output formatting.
All commands accept the following shared options unless stated otherwise:
--root,-r, or trailing argument — Directory,.sln/.slnx/.slnf, or project file to scan. Defaults to the current working directory.--depth,-d— Directory recursion depth when--rootis a folder. Default:3(max32).--log,-v,--verbosity—Debug,Verbose,Info,Warning, orError. Default:Warning.--concurrency— Degree of parallelism for project evaluation; use1for sequential. Default:max(1, processorCount / 2).--markdown,-md— Emit markdown table output where supported. Default:false.--vstoolspath,-vs— ExplicitVSToolsPathfor MSBuild evaluation.--novstoolspath,-novs— Skip automaticVSToolsPathresolution.
Purpose: enumerate build output, report what would be deleted, and either emit a deletion script (default) or delete the files.
Options
--non-current,--noncurrent,-nc— Restrict deletion to target-framework-specific directories not listed in the project’s current TFMs. Default:false.--obj,-obj— Includeobj/BaseIntermediateOutputPathdirectories. Default:false(bin-only).--keep-assets— When cleaningobj, preserve NuGet restore artifacts (project.assets.json, etc.) and only delete build-output subdirectories. Default:false.--publish— Also clean publish output (PublishDir) and pack output (PackageOutputPath). Covers explicitly configured publish directories and, in the artifacts layout,artifacts/publish/<project>/andartifacts/package/. Default:false, because publish output is often kept on purpose for a deployment. A package output directory is never deleted as a whole: it is usually shared (a local feed,artifacts/package/<config>/), so only the project's own<PackageId>.<version>.nupkg/.snupkgfiles directly in it are deleted, and only for projects that pack (IsPackablenotfalse). The file name only makes a file a candidate; the id in the package's own.nuspecdecides, because a NuGet id may end in a numeric segment andFoo.1.2.0.nupkgisFoo.1version2.0as readily asFooversion1.2.0. A file that cannot be read as a package is left alone.--test-results— Also cleanTestResults/(whatdotnet testwrites:.trx, coverage) next to each project (VSTestResultsDirectorywhen the project sets it) and next to its solution. Default:false.--interactive,-i— Pick the directories from a list grouped by project before anything is written or deleted. Every category is marked;--obj,--publishand--test-resultsonly decide what starts out checked (bin always does). Keys:b/o/p/g/ttoggle bin, obj, publish, package and test results for every project on the top line, or for one project on its line or one of its rows;spacetoggles a directory (a whole project or everything on a header line);a/nall or none;enterconfirms;esccancels. Each header shows its categories as[X],[ ]or[-]for a partly checked one, with the selected and total size. Every directory row shows the fully qualified path that would be deleted — never a relative one — cut in the middle (C:\...\MyApp\bin\Debug\net10.0) when the terminal is too narrow for it. It deletes: afterenterit asks once for the whole selection (Delete 12 directories (340.2 MiB)?, default no) and then removes it, without a question per directory unless--confirmis given explicitly;--forceskips that question too. Passing--output-file/-owrites the deletion script for the selection instead of deleting it. Needs an interactive terminal.--output-file,-o— Where to write the deletion script (clean.cmdorclean.shby default depending on OS).--delete— Execute deletions instead of just generating scripts. Default:false(dry-run).--force— Skip confirmation prompts (requires explicit--rootto avoid accidental repo-wide deletes). In non-interactive contexts (CI / piped stdin) a missing confirmation is treated as "no" (skip), so--forceis required to actually delete unattended.--confirm— Intended confirmation granularity for--delete(None,Sln,Project,Directory; defaultDirectory). Note: not yet wired up — currently only--forceaffects prompting.- Global options (
--root,--depth,--log,--concurrency,--vstoolspath,--novstoolspath) apply.
Behavior
- Resolves the root (directory or solution) and recursion depth, then resolves
VSToolsPathunless--novstoolspathis set. - Enumerates solutions/projects, evaluates MSBuild properties per configuration, and locates
bin/objoutput directories. - Marks candidate directories, honoring:
--non-currentto only select TFM folders that are no longer referenced.- Safety checks that avoid touching project roots or nested solutions.
- Default dry-run writes an OS-specific script to
--output-file(or prints to console) with sizes and file counts. - With
--delete, the tool prompts per directory (or skips prompts with--force) and removes directories immediately.
Example
bld clean --root C:\src\MyRepo --depth 4 --obj
bld clean --root C:\src\MyRepo -i --delete # pick per project, then delete what is checkedPurpose: compute what would be cleaned and show totals without generating scripts or deleting anything.
Options
--non-current,--noncurrent,-nc— Only report TFM directories that no longer match current project TFMs. Default:false.--obj,-obj— Includeobjdirectories in the statistics. Default:false.--keep-assets— With--obj, preserve NuGet restore artifacts and only count build-output subdirectories. Default:false.--publish— Include publish output (PublishDir) and pack output (PackageOutputPath) in the statistics. Default:false.--test-results— IncludeTestResults/directories next to projects and solutions. Default:false.- Shares all global options (
--root,--depth,--log,--concurrency,--markdown,--vstoolspath,--novstoolspath).
Behavior
- Uses the same discovery and safety logic as
cleanbut never writes files or deletes anything. - Produces a table with file counts, KiB/MiB sizes, and TFM hints for each marked directory plus a total row.
- Reports “No directories marked for deletion” when nothing qualifies, making it safe to run in automation.
Example
bld stats --root MySolution.sln --non-current- What it does: analyzes NuGet
PackageReferenceusage across projects; can aggregate to a solution-wide view. - How it works: evaluates projects via MSBuild, parses package references, applies optional whitelist/blacklist categorization, and optionally aggregates with
--aggregate/--show-projects.GlobalPackageReferenceandPackageDownloaditems are listed too, marked(global)and(download). With--transitivethe resolved dependency graph fromproject.assets.jsonis included.
Helpful when your favorite agent creates your shiny new project but adds a lot of strange nuget package references. Or even your co-worker.
- What it does: migrates target frameworks (e.g.,
net6.0→net8.0). With--update-packagesit also updates the packages to versions that support the new target, throughoutdated. - How it works: scans solutions/projects, infers current TFMs, optionally auto-detects target TFM, and rewrites the TFM when
--applyis set.--update-packagesthen runsoutdated --applyon the same input, so the compatibility check sees the new frameworks.
Helpful when your favorite agent creates your shiny new project targeting a old version of .NET.
- What it does: converts a solution to Central Package Management by creating
Directory.Packages.propsand stripping per-project version attributes. - How it works: aggregates package versions across projects, resolves conflicts, writes the props file, and updates project files when
--apply(with optional--overwrite).
- What it does: lists packages with newer versions and can update them, in whole or in part.
- How it works: queries NuGet feeds for newer versions, respects TFM compatibility unless
--skip-tfm-check, and applies updates when--apply(with optional--prerelease).--max-bumpcaps how far a package may move,--package/--excludenarrow the set by wildcard, and the declared dependency ranges of the selected packages are checked before anything is written. - (dotnet-outdated is another .NET tool which updates NuGet package versions)
- What it does: finds Dockerfiles and SDK-style projects using container build properties, migrates Dockerfiles to those properties, and validates and consolidates the properties projects already carry.
- How it works: scans the repo (or specific root) and reports Dockerfile paths, project names, or both depending on
--list,--projects, or--all.--migratereads each Dockerfile's runtime stage and writes the equivalentContainer*properties and items into the project it builds (dry run unless--apply).
- What it does: shows where MSBuild properties come from across your projects and lists imported
Directory.Build.propsfiles. - How it works: evaluates each project and reports property provenance;
--listshows just the props-file import tree,--propertiesfilters to specific properties, and--no-overriddenhides shadowed values.
--whitelist-blacklist-file,--wbf— Path to categorization rules.--aggregate,--agg— Collapse results across projects (aggregate view). Default:true. Pass--aggregate falsefor the per-project view.--show-projects,--sp— When aggregating, list referencing projects. Default:true.--transitive— Also list the packages restore resolved through other packages, read from each project'sproject.assets.json(underMSBuildProjectExtensionsPath, i.e.obj/). Requires a priordotnet restore; a project without the file is reported with a warning and listed with its direct references only. Transitive packages are categorized and matched against the whitelist/blacklist like direct ones and show which packages pull them in. Default:false.
Example:
bld nuget --root C:\src\MyRepo --aggregate false
bld nuget --root C:\src\MyRepo --transitive --wbf packages.rules--from— Comma-separated source TFMs (auto-detected when possible).--to— Target TFM (auto-detected from installed SDKs when omitted).--apply— Persist changes instead of a dry-run.--update-packages— With--apply, runoutdated --applyon the same input once the frameworks are written. That gives the migration everythingoutdateddoes: versions are checked for compatibility with the new target framework, capped by--max-bumpand the saved package policies, checked for dependency conflicts, and written toDirectory.Packages.propsunder central package management. Sources come from thenuget.confighierarchy. As inoutdated, only the Release configuration is evaluated and references under aConditionare left alone. Without--applythe packages are not checked, because they would be tested against the frameworks the projects still have.--max-bump <major|minor|patch>— With--update-packages: the largest version step to propose, as inoutdated --max-bump. Default:major.--update-global-json— With--apply, setsdk.versionin the governingglobal.jsonto the highest installed SDK of the target's major (prereleases only whenallowPrereleaseis set). Without--apply, report what would change. Only the version line is rewritten; indentation, line endings and BOM are kept.
global.json. The command always looks for the global.json that governs the input (walking up from its directory, like the SDK does) and warns when its pin cannot build the target framework: a pinned major below the target with any rollForward other than latestMajor blocks the build, and major only rolls forward when the pinned SDK is not installed. Nothing is written without --update-global-json.
Example:
bld tfm --root MySolution.sln --to net9.0 --applyThe command will scan for consistent TFMs, infer .NET SDK versions, and report conflicts before applying changes.
--apply— Modify projects and createDirectory.Packages.props. Default: dry-run.--overwrite— Replace an existingDirectory.Packages.props.
Example:
bld cpm --root MySolution.sln --apply --overwrite-
--apply— Update packages in-place. Default: dry-run/report only. -
--skip-tfm-check— Ignore target framework compatibility checks. -
--prerelease,--pre— Consider prerelease package versions. -
--orphaned— ListPackageVersionentries inDirectory.Packages.propsthat have no matchingPackageReferenceand have a newer version on NuGet. Report-only; works for project or solution input. -
--comment-orphans— With--apply, comment out outdated orphan entries. Only honored for solution input (.sln/.slnx/.slnf), since a single project can't see all CPM consumers. Implies--orphaned. -
--interactive,-i— Pick the packages to update from a grouped list before applying, and pick the target version per package; surfaces dependency version conflicts when you skip a needed package. Implies--apply. Requires an interactive terminal — with redirected input the command fails instead of prompting into the void.Keys: up/down move, space toggles the row (or the whole family on a group line), left/right move the package between the versions the feeds offer for it — highest patch, highest minor, highest major — or, on a group line, cap the whole family at a bump class (the line shows the class and the arrows that still do something; a package with nothing at or below that class is unchecked, and checked again once the cap admits it), a/n select all or none, p sets or clears a persistent "no major" policy for the package (or the family's pattern on a prefix group line, see Package policies below), enter confirms, esc cancels without writing anything.
--max-bumponly decides where each row starts; anything the cap held back is in the list too, unchecked, one keypress away. -
--group-by <prefix|bump|none>— How--interactivegroups the list:prefix(longest common package id prefix, the default),bump(patch/minor/major) ornone(one flat list). Toggling a group line takes its packages with it, so a patch day is one keystroke per family. Passing the option explicitly also groups the report table (and adds aGroupcolumn to--markdown); without it the report is unchanged. -
--group-depth <n>— Maximum prefix length in dot-separated segments for--group-by prefix. Default:2, soMicrosoft.Extensions.*is a group butMicrosoft.Extensions.Logging.*is not. -
--group-min <n>— Smallest number of packages a prefix group must have. Default:2. Packages left over land in(other). -
--preselect <all|no-major|patch|none>— Which rows--interactivestarts out with a check mark on. Default:all, as before. Each row shows its bump class colored (greenpatch, yellowminor, redMAJOR), sono-majormakes "no breaking updates today" one keystroke per exception. -
--max-bump <major|minor|patch>— Largest version step to propose, relative to the version a package is pinned at now:major(no cap, the default),minor(same major, so no breaking change per SemVer) orpatch(same major and minor). -
--max-bump-for "<pattern>=<major|minor|patch>"— Override--max-bumpfor the packages matching a pattern. Same wildcard syntax as--package, may be repeated, accepts;-separated lists; the most specific matching pattern wins and ties go to the one given last. -
--package <pattern>,-p— Only consider packages whose id matches one of these patterns. Supports*wildcards, is case-insensitive, may be repeated, and accepts;-separated lists. Default: all packages. -
--exclude <pattern>— Skip packages whose id matches one of these patterns. Same syntax as--packageand applied after it. -
--allow-conflicts— Update packages even when a dependency they require stays at a version that does not satisfy their declared range. Without this, such packages are held back. -
--verify-restore— After--apply, rundotnet restoreon the input and fail the command when NuGet reports errors. -
--source <name|url>— Package source(s) to query: the name of a source innuget.configor a v3 feed URL. May be repeated. Overrides the configuration, including package source mapping. -
--ignore-source-mapping— Query every enabled source for every package instead of honoring thepackageSourceMappingsection. -
--ignore-policy— Do not apply the saved package policies for this run.--max-bump-forstill applies, and policies set in the picker are still saved. -
--eval-cache— Skip the MSBuild evaluation of a project configuration when nothing that fed its last evaluation has changed. An entry stores the extracted package and project references together with a SHA-256 of the project file and every import outside the SDK (Directory.Build.props,Directory.Packages.props, restore-generated props, package build files); the key also covers the global properties, the MSBuild version and whichDirectory.Build.*/Directory.Packages.propsfiles exist between the project and the root, so a newly added one invalidates the entry. Entries live under$BLD_HOME/cache/eval(default:%LOCALAPPDATA%\bldon Windows,~/.local/share/bldon Linux) and are safe to delete. Opt-in because of what it cannot see: MSBuild properties coming from environment variables,Exists()conditions on files that are not imported, and changes inside the SDK or a Visual Studio installation at the same version. The run reports how many configurations came from the cache.
Item types. Besides PackageReference (inline, VersionOverride, or centrally managed), outdated checks GlobalPackageReference entries in Directory.Packages.props (updated in place there) and PackageDownload items (compared by their highest bracketed version, updated as [x.y.z], and exempt from the TFM check because a download is never referenced). A PackageVersion that only a GlobalPackageReference uses is not an orphan.
Package policies. A policy is a persistent --max-bump-for: a rule like "MassTransit* at most minor" that every outdated run applies without further options, so a package you never want lifted across a major does not have to be excluded by hand each time. Rules live in policy.json under $BLD_HOME (default: %LOCALAPPDATA%\bld on Windows, ~/.local/share/bld on Linux):
{
"Rules": [
{ "Match": "MassTransit*", "MaxBump": "minor", "Reason": "v9 changes license", "Since": "2026-09-18" }
]
}Match uses the --package wildcard syntax; the most specific rule wins, ties go to the later one. Precedence is --max-bump-for over policy over --max-bump, so a global --max-bump major does not lift a policy; --max-bump-for "MassTransit*=major" or --ignore-policy does. In --interactive, p on a package row saves a "no major" rule for that package id and moves the row down to its highest minor or patch (a package with nothing below its major leaves the selection); on a prefix group line the rule is the family's pattern, so it also covers members that are not outdated today. p on a row that already has a rule removes that rule, including from every other row the same pattern covered. The file is written as soon as the picker is confirmed. Held versions are reported with their source (2 by --max-bump minor, 1 by policy), and -v Info names the rule and reason per package. Rules for packages that are not outdated right now can be removed by editing the file.
Undoing a run. Every --apply (and --interactive, and tfm --update-packages) that changes a file records what it wrote — file, package, value before and after, one entry per element — under $BLD_HOME/history/<hash of the input>/, newest 20 runs per input. bld outdated undo [<root>] takes the newest run back: it prints a table of what it would revert, asks once, writes, and drops the reverted edits from the record so a second undo pops the run before it. A value that no longer reads as the run left it — changed by hand, or by a later run — is skipped and named, never overwritten; when a later recorded run wrote it, the message says which one to undo first. Only bld's own edits are covered: policies, the target framework change from tfm, and anything you changed yourself are out of scope, and there is no redo.
--list— Show the recorded runs for this input, newest first, and exit.--run <n>— Revert runnfrom--listinstead of the newest.--package <pattern>,-p/--exclude <pattern>— Revert only part of a run; same syntax as onoutdated. What is not reverted stays recorded.--interactive,-i— Pick from a grouped list like the update picker: one row per package showingnow -> back to, space toggles, a/n all or none, enter reverts, esc cancels. Packages that changed since are shown greyed and cannot be picked. Prints the-p ... --yesline that repeats the choice.--yes,-y— Skip the confirmation; required without an interactive terminal.--verify-restore— Rundotnet restoreafter reverting and fail on NuGet errors.
bld outdated undo # revert the last run on the current directory
bld outdated undo MyRepo.slnx --list
bld outdated undo MyRepo.slnx -p "MassTransit*" --yes
bld outdated undo MyRepo.slnx -iPackage sources. outdated reads the nuget.config hierarchy as NuGet does, starting from the directory of the input (repo config, user config, machine config): enabled sources, packageSourceMapping, and packageSourceCredentials (clear-text or %ENV_VAR% references; credentials are sent as Basic auth, which is what Azure Artifacts and GitHub Packages expect for a PAT). Each source's service index is fetched once to find its registration endpoint. When several sources may serve a package, the highest version wins. A source that is unreachable, a v2 feed, or a local directory is skipped for the run with a warning. A package that source mapping assigns to no source is skipped with a warning and does not affect the exit code. Without any configured source, nuget.org is used.
Examples:
bld outdated --root C:\src\MyRepo --prerelease
bld outdated --root C:\src\MyRepo --source internal --source nuget.org
bld outdated --root C:\src\MyRepo --max-bump minor --apply
bld outdated --root C:\src\MyRepo -p "Serilog.*" -p "xunit*" --exclude "Serilog.Sinks.Seq" --max-bump minor --apply
bld outdated MyRepo.slnx --apply --max-bump minor --max-bump-for "Microsoft.Extensions.*=patch" --max-bump-for "Serilog*=major"The held column names the newest version that --max-bump refused, so a capped run does not read as "up to date". A package that has nothing but a held-back version is listed with current equal to latest, and --apply leaves it untouched. In --interactive it is a row like any other, unchecked, reachable with left/right — so capping the run at minor and still taking one specific major is a keypress, not a second run.
Every version the picker offers has passed the same listing, prerelease and target framework checks as the default target, because one lookup per package collects the highest compatible version in each bump class instead of stopping at the first usable one.
After the selection is settled, --interactive prints the command line that reproduces it without any prompt — a fully selected prefix group collapses into a single -p "<prefix>.*", and any target above the cap becomes a --max-bump-for <id>=<level>. It reproduces the selection, not necessarily the result: a later run can see newer versions than this one did.
Before writing, the command checks the packages it is about to update in both directions: a package whose new version requires a dependency at a version it will not end up at is held back, and so is a package whose new version falls outside the range that another reference staying where it is declares on it (A 9.0.1 breaks Q 3.1.0, which requires A [8.0.0, 9.0.0)). In --interactive the second case offers to include the blocking package's own update instead. The check is a heuristic that sees direct references only: transitive chains are outside its reach, and a pinned version the feed no longer lists has no manifest to check, so --verify-restore is the only complete answer. Held-back packages are a normal outcome and do not change the exit code; failures to analyze a project, look up a package, or restore do.
--list,-l— Show file paths only. Default:false.--projects,-p— Scan for SDK-style container projects. Default:false.--all,-a— Scan Dockerfiles and container projects together.--migrate,-m— Migrate each Dockerfile to SDK container properties on the project it builds. Prints thePropertyGroup/ItemGroupit would add per Dockerfile; nothing is written without--apply. Needs no MSBuild evaluation.--apply— With--migrate, write the properties into the project files. The file's indentation, line endings and BOM are kept; the new groups are appended before</Project>under a comment naming the Dockerfile.--delete-dockerfile— With--migrate --apply, delete the migrated Dockerfile and strip the Visual Studio container-tools leftovers from the project: theDocker*properties (DockerDefaultTargetOS,DockerfileContext, ...), theMicrosoft.VisualStudio.Azure.Containers.Tools.Targetspackage reference and a<None Include="Dockerfile" />item. Without it the Dockerfile stays and those settings are only reported.--force— With--migrate, migrate a Dockerfile whose runtime image has instructions the SDK cannot express (see below). They are listed and dropped.--run-as-root— With--migrate, writeContainerUser=rootfor every Dockerfile withoutUSERinstead of asking (see theUSERrow below).--validate— Check the SDK container settings every project under the root carries (projects without any are not listed) and, with--apply, rewrite the findings that have an exact equivalent. See Validation below.--interactive,-i— With--migrateor--validate, ask instead of--apply: each Dockerfile's plan is shown, then whether to migrate it anyway when it has instructions the SDK cannot express (default no), whether to write the settings (default yes) and whether to delete the Dockerfile and the Visual Studio container-tools settings (default no;--delete-dockerfileanswers yes for all). Each fixable validation finding is asked for (default yes). Answers are written immediately; a non-interactive terminal is an error.
Examples:
bld containerize --root C:\src\MyRepo --all --depth 5
bld containerize --migrate --root C:\src\MyRepo
bld containerize --migrate --apply --delete-dockerfile src\Api\Api.csproj
bld containerize --migrate -i src
bld containerize --validate --apply src\Api\Api.csproj
bld containerize --validate -iValidation. The project file is read as XML, so a value that comes from Directory.Build.props or an import is unknown and left alone; a conditioned setting is reported but never rewritten. Findings with a fix are written by --apply; the rest need a decision.
| Finding | Fix with --apply |
|---|---|
ContainerBaseImage is exactly the image the SDK computes for the project (aspnet:8.0 on a Web SDK net8.0 project) |
removed — unless ContainerFamily is set too, since removing the pin would activate it |
ContainerBaseImage is a family variant of that image (runtime-deps:10.0-azurelinux3.0-distroless-extra on an AOT net10.0 project) |
replaced by ContainerFamily (azurelinux3.0-distroless-extra); removed when ContainerFamily already says the same |
either of those with a platform suffix (-amd64, -arm64v8, -arm32v7) |
reported — ContainerFamily cannot carry the platform, so without the pin the image follows the RuntimeIdentifier (the building machine's architecture by default); ContainerRuntimeIdentifier pins it |
ContainerBaseImage pins another repository or version than the SDK would pick; ContainerFamily next to a ContainerBaseImage (ignored by the SDK) |
reported |
ContainerImageName (obsolete, CONTAINER003) |
renamed to ContainerRepository; removed when equal to an existing one; reported when it overrides a different one |
ContainerEntrypoint/ContainerEntrypointArgs (deprecated since .NET 8) |
renamed to ContainerAppCommand/ContainerAppCommandArgs with ContainerAppCommandInstruction=Entrypoint when the instruction is None (the same ENTRYPOINT and CMD); reported otherwise, since the SDK's app command is the CMD behind such an entrypoint and the rename would drop it |
ContainerWorkingDirectory /app |
removed (the SDK default) |
an element with the Container prefix the SDK does not read (ContainerImage) |
reported |
ContainerAppCommandInstruction, ContainerImageFormat, LocalRegistry outside their allowed values; a tag or ContainerRepository the registry would reject; ContainerImageTag holding a list or set next to ContainerImageTags; a ContainerPort that is not a port or not tcp/udp; ContainerRuntimeIdentifiers not a subset of RuntimeIdentifiers |
reported |
Migration. The runtime image is the last stage plus every stage it derives FROM, folded in order the way Docker layers them; the build stages are what dotnet publish /t:PublishContainer replaces. ARG defaults and ENV values are substituted ($X, ${X}, ${X:-default}); a build arg without a default is left as written and reported. The project is the one the Dockerfile publishes, failing that the one it builds, the one whose dll the ENTRYPOINT runs, the only .csproj it mentions, or the only .csproj next to it. A project that already has any SDK container setting (ContainerBaseImage, ContainerFamily, ContainerRepository, ContainerImageTag(s), ContainerRegistry, ContainerRuntimeIdentifier(s), ContainerArchiveOutputPath, LocalRegistry, ContainerUser, ContainerPort items, ...) is skipped.
| Dockerfile | Project |
|---|---|
FROM of the runtime stage |
ContainerBaseImage — omitted when it is exactly the image the SDK computes for the project (aspnet for the Web SDK, runtime otherwise, runtime-deps when self-contained or AOT, tagged with the target framework's version), so the image follows a later tfm migration instead of pinning the old runtime. A variant of that image (aspnet:8.0-alpine, 8.0-noble-chiseled) becomes ContainerFamily (alpine, noble-chiseled), which the SDK appends to the tag it computes, so the variant stays and the version still follows the target framework. Windows tags (nanoserver, windowsservercore) stay an explicit ContainerBaseImage |
EXPOSE 8080, EXPOSE 53/udp |
ContainerPort (Type only for non-tcp) |
ENV, LABEL, MAINTAINER |
ContainerEnvironmentVariable, ContainerLabel |
WORKDIR |
ContainerWorkingDirectory — omitted for the SDK default /app |
USER |
ContainerUser |
no USER |
the image ran as root, while the SDK runs .NET 8+ Microsoft images as the non-root app user. The command asks per Dockerfile whether to keep root (default no) and writes ContainerUser=root on yes; --run-as-root answers yes for all, and without a terminal the SDK default is kept and noted |
ENTRYPOINT ["dotnet", "App.dll"] or ["./App"] |
nothing: that is the SDK's default app command |
other ENTRYPOINT |
ContainerAppCommand items (shell form becomes /bin/sh -c ...) with ContainerAppCommandInstruction=Entrypoint, and CMD as ContainerDefaultArgs: the SDK makes the app command the ENTRYPOINT and the default args the CMD. The deprecated ContainerEntrypoint items are not written |
CMD without ENTRYPOINT |
ContainerDefaultArgs with ContainerAppCommandInstruction=None, unless it is the default app command: the image has no ENTRYPOINT and CMD stays overridable |
COPY --from=<build stage> <publish output> . |
nothing: the SDK publishes into the image itself |
RUN, ADD, VOLUME, HEALTHCHECK, SHELL, STOPSIGNAL, ONBUILD in the runtime image, COPY from the build context, COPY --from of anything but a stage's dotnet publish/build output |
not migrated: listed per Dockerfile and blocks it unless --force |
EnableSdkContainerSupport=true is always written (console projects on older SDKs need it). Extra dotnet publish arguments in the Dockerfile (-r linux-musl-x64, /p:...) and build-stage RUN lines that install tooling are reported as notes, since the SDK now builds on the host. Two Dockerfiles for one project migrate the first (alphabetically) and skip the second. The exit code is 1 only when a project file could not be written.
--list,-l— Only list importedDirectory.Build.propsfiles as a tree, without property contents. Default:false.--properties— Comma-separated properties to trace (e.g.TargetFramework,Nullable); when set, only these are shown.--no-overridden— Hide properties that originate fromDirectory.Build.propsbut are overridden later in evaluation. Default:false.
Example:
bld build-props --root C:\src\MyRepo --properties TargetFramework,LangVersion- Generate a deletion script for inspection:
bld clean --root C:\src\MyRepo --depth 3 -o clean.cmd
- Preview disk impact only:
bld stats --root MySolution.sln --obj --non-current
- Audit NuGet usage across solutions:
bld nuget --root C:\src\MyRepo --aggregate --show-projects
- List Dockerfiles without parsing:
bld containerize --root . --list
- MSBuild evaluation happens in-process; evaluation failures are reported but do not abort the run.
- Auto-resolving MSBuild toolsets may require Visual Studio or the .NET SDK to be installed.
- Beta commands surface rich diagnostics but are still evolving; file issues with exact command lines and logs when something looks off.
- Discovery pipeline:
SlnScannerfinds solutions under--root/--depth,SlnParserenumerates project configs, andProjParserevaluates MSBuild properties (OutDir, BaseIntermediateOutputPath, TFMs).VSToolsPathis resolved automatically unless--novstoolspathis specified. - Marking logic:
MarkDeleteProcessorcollects bin/obj candidates, deduplicates directories shared across configurations, and refuses to touch paths that look like project roots or nested solutions. When--non-currentis set, TFM directories matching the project’s declared TFMs are skipped. Projects withUseArtifactsOutput=trueare handled throughartifacts/bin/<project>/, where every subdirectory named<config>[_<tfm>][_<rid>]is a candidate. With--publish,PublishDirandPackageOutputPathare added in a second pass and dropped when they already sit inside a marked build-output directory (the default case). AnOutDirthat matches none of the known layouts is reported as a warning instead of being skipped silently. - Stats vs clean:
statshands results toMarkDeleteResultStatsProcessor, which enumerates files (depth-limited) to compute counts and KiB/MiB totals without creating any output files.cleanhands results to eitherMarkDeleteResultBatchFileProcessor(default) orMarkDeleteResultDeleteProcessorwhen--deleteis set. The batch processor writes platform-specific scripts (respecting--output-file) and prints a table. The delete processor prompts per directory unless--forceis used.
- Safety rails: depth defaults to 3,
--forcerequires an explicit--root, and the tool stops silently when nothing is marked. Errors are aggregated viaErrorSinkand printed after processing. - Other commands reuse the same MSBuild initialization and scanning primitives, layering command-specific processors (NuGet analysis, TFM migration with NuGet metadata checks, CPM rewrite helpers, outdated package lookups, and Dockerfile/project scanners).
bld is developed for repositories that accumulate large volumes of build output. Review scripts before executing deletions, especially when using --delete.