diff --git a/.agents/skills/adding-a-migration-step/SKILL.md b/.agents/skills/adding-a-migration-step/SKILL.md
index 85a2e7307..a8f14eb9a 100644
--- a/.agents/skills/adding-a-migration-step/SKILL.md
+++ b/.agents/skills/adding-a-migration-step/SKILL.md
@@ -6,12 +6,12 @@ description: How to make fallout-migrate handle a new rename, removal, or rewrit
`fallout-migrate` runs a fixed, ordered list of `IMigrationStep` implementations,
built in `src/Fallout.Migrate/Migration.cs`. A **step is one operation over one
set of files**, and it owns the rewrite rules for those files.
-`RewriteCsprojsStep` holds the rules for `*.csproj`; `RewriteCsFilesStep` holds
+`RewriteCsprojsStep` holds the rules for `*.csproj` and `*.props`; `RewriteCsFilesStep` holds
the rules for `*.cs`.
## Adding a new rename, removal, or rewrite
-1. Find the step for that file type — `RewriteCsprojsStep` for `*.csproj`,
+1. Find the step for that file type — `RewriteCsprojsStep` for `*.csproj` and `*.props`,
`RewriteCsFilesStep` for `*.cs`, `RewriteBootstrapScriptsStep` for the
bootstrap scripts.
2. Add a `private static readonly Regex` field to that step, with a comment
diff --git a/docs/Migration/from-nuke.md b/docs/Migration/from-nuke.md
index 934bb0bf4..0289df2ab 100644
--- a/docs/Migration/from-nuke.md
+++ b/docs/Migration/from-nuke.md
@@ -15,7 +15,7 @@ Done. Read on if anything looks unusual, or you want to know what the tool did.
## The recommended path: `fallout-migrate`
-`fallout-migrate` is a global `dotnet` tool that performs the entire migration in one command. It rewrites `PackageReference`s, `using` directives, MSBuild properties, bootstrap scripts, env vars, and renames `.nuke/` → `.fallout/`. It's idempotent and has a `--dry-run` mode.
+`fallout-migrate` is a global `dotnet` tool that performs the entire migration in one command. It rewrites `PackageReference`s and central package versions (`Directory.Packages.props`), `using` directives, MSBuild properties, bootstrap scripts, env vars, and renames `.nuke/` → `.fallout/`. It's idempotent and has a `--dry-run` mode.
### Step 1 — install the tool
diff --git a/src/Fallout.Migrate/Common/MigrationFileOperations.cs b/src/Fallout.Migrate/Common/MigrationFileOperations.cs
index a5ba5e44b..3311b7159 100644
--- a/src/Fallout.Migrate/Common/MigrationFileOperations.cs
+++ b/src/Fallout.Migrate/Common/MigrationFileOperations.cs
@@ -50,7 +50,7 @@ public static void ApplyRewrite(
{
original = path.ReadAllText();
}
- catch (IOException ex)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
summary.Warnings.Add($"could not read {RelativePath(context.RootDirectory, path)}: {ex.Message}");
return;
diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
index c69d15e77..6be7c5ab5 100644
--- a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
+++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
@@ -1,48 +1,54 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using Fallout.Common.IO;
using Fallout.Migrate.Common;
namespace Fallout.Migrate.Steps;
///
-/// Rewrites every *.csproj file under the repository root: Nuke.* package/project
-/// references become Fallout.* (pinning the current Fallout version where an inline
-/// Version attribute was present), Nuke* MSBuild properties are renamed to
-/// Fallout*, and stale explicit System.Security.Cryptography.Xml pins are stripped.
+/// Rewrites every *.csproj and *.props file under the repository root: Nuke.*
+/// package/project references and central PackageVersion items become Fallout.*
+/// (pinning the current Fallout version where a Version or VersionOverride attribute
+/// was present), Nuke* MSBuild properties are renamed to Fallout*, version properties
+/// are bumped wherever they are defined, and stale explicit
+/// System.Security.Cryptography.Xml pins are stripped from *.csproj files.
///
internal sealed class RewriteCsprojsStep : IMigrationStep
{
- // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X
- // at the current Fallout version. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as
- // Fallout.* packages and produce NU1603 ("not found, falling back to next-higher") which
- // `WarningsAsErrors` in the migrated project escalates. Bumping in the same pass avoids
- // a broken post-migrate build (#217). Tolerates extra attributes between Include and Version
- // (e.g. `PrivateAssets="all"`).
- // We don't mach MSBuild variables (`$(...)`) here, because they are handled below
- private static readonly Regex nukePackageWithInlineVersionPattern = new(
- @"(?[A-Z][A-Za-z0-9.]+)(?""[^>]*?\s+Version="")(?!\$\()[^""]+",
+ // Package items: PackageReference, central PackageVersion (Directory.Packages.props, #492)
+ // and PackageDownload. A Nuke.X item becomes Fallout.X, and a literal Version or
+ // VersionOverride on it is pinned to the current Fallout version, whatever the attribute
+ // order. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as Fallout.* packages and
+ // produce NU1603 ("not found, falling back to next-higher") which `WarningsAsErrors` in the
+ // migrated project escalates. Pinning in the same pass avoids a broken post-migrate build
+ // (#217). MSBuild variables (`$(...)`) are handled by HandleMsBuildVariable below.
+ private static readonly Regex packageItemPattern = new(
+ @"<(?:PackageReference|PackageVersion|PackageDownload)\b[^>]*>",
RegexOptions.Compiled);
- // PackageReference / ProjectReference `Include="Nuke.X"` → `Include="Fallout.X"` — namespace
- // only. Catches references that DON'T have an inline Version (central package management).
- // Must run AFTER NukePackageWithInlineVersionPattern so it only touches what's left.
- private static readonly Regex packageReferencePattern =
+ // The `Nuke.` prefix of an Include, Update or Remove value: `Include="Nuke.X"` → `Include="Fallout.X"`.
+ // Pass 1 applies it to package items. Pass 2 applies it to every item that pass 1 didn't touch.
+ private static readonly Regex nukeItemNamePattern =
new(@"(?<=\b(?:Include|Update|Remove)="")Nuke\.(?=[A-Z])", RegexOptions.Compiled);
- // Detects MSBuild variables used by already-rewritten Fallout.* PackageReferences:
- // Version="$(MyVar)". Scoped to Fallout.* so variables shared with unrelated packages
- // aren't mistaken for Fallout version variables.
- private static readonly Regex falloutPackageReferenceVariablePattern = new(
- @"]*?Version=""\$\((?[^)]+)\)""",
+ // The literal value of a Version or VersionOverride attribute. PackageDownload needs an exact
+ // range (`[10.1.0]`), so the brackets stay outside the match. A version range such as
+ // `[10.1.0,)` or `(10.1.0,11.0.0)` doesn't match and is kept as written: replacing only
+ // one bound would leave an invalid range.
+ private static readonly Regex literalVersionPattern = new(
+ @"(?<=\bVersion(?:Override)?=""\[?)(?!\$\()[^""\[\],()]+(?=\]?"")",
RegexOptions.Compiled);
- // Same variable-usage detection, but for PackageReferences that are NOT Fallout.* — used to
- // detect a variable ambiguously shared between a Fallout package and an unrelated one.
- private static readonly Regex nonFalloutPackageReferenceVariablePattern = new(
- @"]*?Version=""\$\((?[^)]+)\)""",
+ // A PackageReference or central PackageVersion whose version is an MSBuild variable:
+ // Version="$(MyVar)". The Include and Version attributes can come in either order.
+ // Used to classify the variables (both Nuke.* and Fallout.* spellings count, because the files
+ // are classified before they are rewritten) and to redirect an ambiguous one.
+ private static readonly Regex variableVersionItemPattern = new(
+ @"<(?:PackageReference|PackageVersion)\b(?=[^>]*\bInclude=""(?[^""]+)"")(?=[^>]*\bVersion=""\$\((?[^)]+)\)"")[^>]*>",
RegexOptions.Compiled);
// MSBuild element/property names that begin with `Nuke` followed by an uppercase
@@ -70,7 +76,8 @@ internal sealed class RewriteCsprojsStep : IMigrationStep
// requires a newer version (10.0.6+) and the conflict trips NU1605 ("Detected package
// downgrade"). Removing the explicit pin lets the transitive version win, which is what the
// migrated project wants (#217). Matches a self-closing element with optional surrounding
- // indentation + trailing newline.
+ // indentation + trailing newline. Applied to *.csproj only: a pin in a *.props file such as
+ // a root Directory.Build.props applies to every project in the repository, not just the build.
private static readonly Regex cryptographyXmlPackageRefPattern = new(
@"^[ \t]*[ \t]*\r?\n?",
RegexOptions.Compiled | RegexOptions.Multiline);
@@ -78,43 +85,76 @@ internal sealed class RewriteCsprojsStep : IMigrationStep
///
public Task ExecuteAsync(MigrationContext context, Summary summary)
{
- foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.csproj"))
+ var files = MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.csproj")
+ .Concat(MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.props"))
+ .ToList();
+
+ // A version variable can be used in one file and defined in another, for example
+ // `$(NukeVersion)` in Directory.Packages.props and `` in Version.props (#492).
+ // So the variables are classified over all files first, then every file is rewritten.
+ var variables = VersionVariables.Collect(files.Select(ReadOrEmpty));
+
+ foreach (var path in files)
{
MigrationFileOperations.ApplyRewrite(
context,
path,
- content => Rewrite(content, context.FalloutVersion),
+ content => Rewrite(
+ content,
+ context.FalloutVersion,
+ variables,
+ isProjectFile: path.ToString().EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)),
summary);
}
return Task.CompletedTask;
+
+ static string ReadOrEmpty(AbsolutePath path)
+ {
+ // An unreadable file is reported by ApplyRewrite; it contributes nothing here.
+ try
+ {
+ return path.ReadAllText();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return string.Empty;
+ }
+ }
}
///
/// Rewrites content, replacing Nuke.* references and MSBuild
/// properties with their Fallout.* equivalents and stripping stale pins.
///
- /// The original .csproj file content.
- /// The Fallout version to pin into rewritten inline-versioned references.
+ /// The original .csproj or .props file content.
+ /// The Fallout version to pin into rewritten versioned references.
+ /// The version variables classified over all files.
+ /// true for a .csproj, false for a .props file.
/// The rewritten content and the number of edits made.
- private static RewriteResult Rewrite(string original, string falloutVersion)
+ private static RewriteResult Rewrite(string original, string falloutVersion, VersionVariables variables, bool isProjectFile)
{
var edits = 0;
var content = original;
- // Pass 1 — combined Include + Version rewrite for Nuke.X PackageReferences with inline Version.
- content = nukePackageWithInlineVersionPattern.Replace(content, m =>
+ // Pass 1 — package items: rename a Nuke.X Include and pin its literal Version/VersionOverride.
+ // One edit per item, however many attributes changed. An item that is already Fallout.X
+ // keeps its pin.
+ content = packageItemPattern.Replace(content, m =>
{
+ var item = nukeItemNamePattern.Replace(m.Value, "Fallout.");
+ if (item == m.Value)
+ {
+ return m.Value;
+ }
+
edits++;
- return m.Groups["prefix"].Value
- + "Fallout." + m.Groups["name"].Value
- + m.Groups["between"].Value
- + falloutVersion;
+ return literalVersionPattern.Replace(item, _ => falloutVersion);
});
- // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (CPM-managed
- // PackageReferences without inline Version, ProjectReferences, MSBuild properties).
- content = packageReferencePattern.Replace(content, _ =>
+ // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (other item types,
+ // MSBuild properties).
+ content = nukeItemNamePattern.Replace(content, _ =>
{
edits++;
return "Fallout.";
@@ -133,64 +173,92 @@ private static RewriteResult Rewrite(string original, string falloutVersion)
return string.Empty;
});
- // Pass 4 — strip the stale System.Security.Cryptography.Xml direct pin.
- content = cryptographyXmlPackageRefPattern.Replace(content, _ =>
+ // Pass 4 — strip the stale System.Security.Cryptography.Xml direct pin (project files only).
+ if (isProjectFile)
{
- edits++;
- return string.Empty;
- });
+ content = cryptographyXmlPackageRefPattern.Replace(content, _ =>
+ {
+ edits++;
+ return string.Empty;
+ });
+ }
- return HandleMsBuildVariable(falloutVersion, content, edits);
+ return HandleMsBuildVariable(falloutVersion, content, edits, variables);
}
- // Pass 5 — extract variables used by Fallout.* PackageReferences, decouple the ones ambiguously
- // shared with non-Fallout packages via a dedicated $(FalloutVersion) property, and bump every
- // variable that's now exclusively Fallout's to the current Fallout version.
- private static RewriteResult HandleMsBuildVariable(string falloutVersion, string content, int edits)
+ // Pass 5 — decouple the variables ambiguously shared with non-Fallout packages via a dedicated
+ // $(FalloutVersion) property, and bump every variable that's exclusively Fallout's to the
+ // current Fallout version, in whichever file defines it.
+ private static RewriteResult HandleMsBuildVariable(string falloutVersion, string content, int edits, VersionVariables variables)
{
- const string falloutVersionVariable = "FalloutVersion";
-
- var (variablesToBump, ambiguousVariables) =
- ClassifyPackageReferenceVariables(content, falloutVersionVariable);
-
(content, int redirectEdits) =
- RedirectAmbiguousVariablesToFalloutVersion(content, ambiguousVariables, falloutVersionVariable);
+ RedirectAmbiguousVariablesToFalloutVersion(content, variables.Ambiguous, VersionVariables.FalloutVersionVariable);
edits += redirectEdits;
- content = EnsureFalloutVersionPropertyExists(content, ambiguousVariables, falloutVersionVariable, falloutVersion,
- ref edits);
+ // Only a file with a redirected reference gets the property. Every other file stays unchanged.
+ if (redirectEdits > 0 && !variables.DefinesFalloutVersion)
+ {
+ content = EnsureFalloutVersionPropertyExists(content, VersionVariables.FalloutVersionVariable, falloutVersion, ref edits);
+ }
- (content, int bumpEdits) = BumpVariableProperties(content, variablesToBump, falloutVersion);
+ (content, int bumpEdits) = BumpVariableProperties(content, variables.ToBump, falloutVersion);
edits += bumpEdits;
return new RewriteResult(content, edits);
}
- // A variable also shared with a non-Fallout package is ambiguous: bumping it directly would
- // change that unrelated package's version too, so it's decoupled instead — the Fallout
- // reference is redirected to a dedicated $(FalloutVersion) property.
- private static (HashSet variablesToBump, HashSet ambiguousVariables) ClassifyPackageReferenceVariables(
- string content, string falloutVersionVariable)
+ ///
+ /// The version variables found in the files of one migration run: those used only by Nuke.*/Fallout.*
+ /// packages (bumped), those also shared with an unrelated package (ambiguous), and whether any
+ /// file already defines the dedicated FalloutVersion property.
+ ///
+ private sealed class VersionVariables
{
- var nonFalloutVariables = nonFalloutPackageReferenceVariablePattern.Matches(content)
- .Select(m => m.Groups["variable"].Value)
- .ToHashSet();
-
- var variablesToBump = new HashSet
+ public const string FalloutVersionVariable = "FalloutVersion";
+
+ // The property is renamed from NukeVersion by msBuildPropertyPattern, so both spellings count.
+ private static readonly Regex falloutVersionPropertyPattern =
+ new(@"<(?:Nuke|Fallout)Version\s*>", RegexOptions.Compiled);
+
+ public HashSet ToBump { get; } = [FalloutVersionVariable];
+ public HashSet Ambiguous { get; } = [];
+ public bool DefinesFalloutVersion { get; private set; }
+
+ // A variable also shared with a non-Fallout package is ambiguous: bumping it directly would
+ // change that unrelated package's version too, so it's decoupled instead — the Fallout
+ // reference is redirected to a dedicated $(FalloutVersion) property.
+ //
+ // Ambiguity is decided by variable name over all files. A per-project property used by a
+ // Fallout package in project A and by an unrelated package in project B is ambiguous, so
+ // A is decoupled even though A's own definition isn't shared. The result is still correct,
+ // it only has more edits than needed.
+ public static VersionVariables Collect(IEnumerable contents)
{
- falloutVersionVariable
- };
+ var result = new VersionVariables();
+ var falloutVariables = new HashSet();
+ var nonFalloutVariables = new HashSet();
- var ambiguousVariables = new HashSet();
+ foreach (var content in contents)
+ {
+ foreach (Match match in variableVersionItemPattern.Matches(content))
+ {
+ var include = match.Groups["include"].Value;
+ var isFalloutPackage = include.StartsWith("Nuke.", StringComparison.Ordinal) ||
+ include.StartsWith("Fallout.", StringComparison.Ordinal);
+ (isFalloutPackage ? falloutVariables : nonFalloutVariables).Add(match.Groups["variable"].Value);
+ }
- foreach (var variable in falloutPackageReferenceVariablePattern.Matches(content)
- .Select(m => m.Groups["variable"].Value))
- {
- (nonFalloutVariables.Contains(variable) ? ambiguousVariables : variablesToBump).Add(variable);
- }
+ result.DefinesFalloutVersion |= falloutVersionPropertyPattern.IsMatch(content);
+ }
+
+ foreach (var variable in falloutVariables)
+ {
+ (nonFalloutVariables.Contains(variable) ? result.Ambiguous : result.ToBump).Add(variable);
+ }
- return (variablesToBump, ambiguousVariables);
+ return result;
+ }
}
private static (string content, int edits) RedirectAmbiguousVariablesToFalloutVersion(
@@ -198,28 +266,29 @@ private static (string content, int edits) RedirectAmbiguousVariablesToFalloutVe
{
var edits = 0;
- foreach (var variable in ambiguousVariables)
+ // Runs after the rename, so only Fallout.* items are redirected. Only the variable name
+ // inside `$(...)` is swapped. The rest of the item stays as written.
+ content = variableVersionItemPattern.Replace(content, m =>
{
- // Matches `Version="$(variable)"` on a Fallout.* PackageReference only, capturing
- // everything up to and including the opening `Version="` so it can be re-emitted
- // unchanged while just swapping the variable reference.
- var redirectPattern = new Regex(
- $@"(]*?Version="")\$\({Regex.Escape(variable)}\)");
-
- content = redirectPattern.Replace(content, m =>
+ var variable = m.Groups["variable"];
+ if (!m.Groups["include"].Value.StartsWith("Fallout.", StringComparison.Ordinal) ||
+ !ambiguousVariables.Contains(variable.Value))
{
- edits++;
- return m.Groups[1].Value + $"$({falloutVersionVariable})";
- });
- }
+ return m.Value;
+ }
+
+ edits++;
+ var offset = variable.Index - m.Index;
+ return m.Value.Remove(offset, variable.Length).Insert(offset, falloutVersionVariable);
+ });
return (content, edits);
}
private static string EnsureFalloutVersionPropertyExists(
- string content, HashSet ambiguousVariables, string falloutVersionVariable, string falloutVersion, ref int edits)
+ string content, string falloutVersionVariable, string falloutVersion, ref int edits)
{
- if (ambiguousVariables.Count == 0 || content.Contains($"<{falloutVersionVariable}>"))
+ if (content.Contains($"<{falloutVersionVariable}>"))
{
return content;
}
diff --git a/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs
index fe1fa19a0..44bdade00 100644
--- a/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs
@@ -220,6 +220,30 @@ public async Task Conflicting_system_security_cryptography_xml_pin_is_stripped()
buildCsproj.Should().Contain(@"Include=""Fallout.Common""");
}
+ [Fact]
+ public async Task Cryptography_xml_pin_in_a_props_file_is_kept()
+ {
+ // A pin in a root Directory.Build.props applies to every project in the repository,
+ // so only the pin in a project file is stripped.
+ // Arrange
+ const string input = """
+
+
+
+
+
+ """;
+
+ (tempDirectory / "Directory.Build.props").WriteAllText(input, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ summary.EditCount.Should().Be(0);
+ (tempDirectory / "Directory.Build.props").ReadAllText().Should().Be(input);
+ }
+
[Fact]
public async Task Other_system_packages_are_left_alone()
{
@@ -480,4 +504,311 @@ public async Task Decouples_ambiguously_used_variables_when_a_property_group_but
.And.Contain("$(FalloutVersion)", Exactly.Once())
.And.Contain("$(PkgVersion)");
}
+
+ [Fact]
+ public async Task Central_package_versions_are_renamed_and_pinned()
+ {
+ // Arrange
+ (tempDirectory / "Directory.Packages.props").WriteAllText("""
+
+
+
+
+
+
+
+ """, eofLineBreak: false);
+ (tempDirectory / "build" / "_build.csproj").WriteAllText("""
+
+
+
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ summary.EditCount.Should().Be(3);
+ (tempDirectory / "Directory.Packages.props").ReadAllText().Should().Be("""
+
+
+
+
+
+
+
+ """);
+ (tempDirectory / "build" / "_build.csproj").ReadAllText().Should().Contain("""""");
+ }
+
+ [Fact]
+ public async Task Version_property_in_an_imported_props_file_is_renamed_and_bumped()
+ {
+ // Arrange
+ (tempDirectory / "Directory.Packages.props").WriteAllText("""
+
+
+
+
+
+
+ """, eofLineBreak: false);
+ (tempDirectory / "Version.props").WriteAllText("""
+
+
+ 10.1.0
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ (tempDirectory / "Directory.Packages.props").ReadAllText()
+ .Should().Contain("""""");
+ (tempDirectory / "Version.props").ReadAllText().Should().Contain("11.0.0");
+ }
+
+ [Fact]
+ public async Task Version_variable_defined_in_another_file_is_bumped()
+ {
+ // Arrange
+ (tempDirectory / "Directory.Packages.props").WriteAllText("""
+
+
+
+
+
+ """, eofLineBreak: false);
+ (tempDirectory / "Version.props").WriteAllText("""
+
+
+ 10.1.0
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ (tempDirectory / "Version.props").ReadAllText().Should().Contain("11.0.0");
+ }
+
+ [Fact]
+ public async Task Version_variable_shared_with_another_package_in_another_file_is_decoupled()
+ {
+ // Arrange
+ (tempDirectory / "Directory.Packages.props").WriteAllText("""
+
+
+ true
+
+
+
+
+
+ """, eofLineBreak: false);
+ (tempDirectory / "build" / "_build.csproj").WriteAllText("""
+
+
+
+
+
+ """, eofLineBreak: false);
+ (tempDirectory / "Version.props").WriteAllText("""
+
+
+ 4.0.0
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ var packages = (tempDirectory / "Directory.Packages.props").ReadAllText();
+ packages.Should().Contain("""""");
+ packages.Should().Contain("11.0.0");
+ (tempDirectory / "Version.props").ReadAllText().Should().Contain("4.0.0");
+ }
+
+ [Fact]
+ public async Task Files_without_a_redirected_reference_are_unchanged_when_a_variable_is_decoupled()
+ {
+ // Arrange
+ (tempDirectory / "Directory.Packages.props").WriteAllText("""
+
+
+ true
+
+
+
+
+
+
+ """, eofLineBreak: false);
+ const string versionProps = """
+
+
+ 4.0.0
+
+
+ """;
+ (tempDirectory / "Version.props").WriteAllText(versionProps, eofLineBreak: false);
+ const string libraryProject = """
+
+
+ net10.0
+
+
+ """;
+ (tempDirectory / "src" / "Lib.csproj").WriteAllText(libraryProject, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ var packages = (tempDirectory / "Directory.Packages.props").ReadAllText();
+ packages.Should().Contain("""""");
+ packages.Should().Contain("11.0.0");
+ (tempDirectory / "Version.props").ReadAllText().Should().Be(versionProps);
+ (tempDirectory / "src" / "Lib.csproj").ReadAllText().Should().Be(libraryProject);
+ }
+
+ [Fact]
+ public async Task Version_variable_before_include_is_decoupled()
+ {
+ // Arrange
+ (tempDirectory / "Directory.Packages.props").WriteAllText("""
+
+
+ 4.0.0
+
+
+
+
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ var packages = (tempDirectory / "Directory.Packages.props").ReadAllText();
+ packages.Should().Contain("""""");
+ packages.Should().Contain("""""");
+ packages.Should().Contain("4.0.0");
+ packages.Should().Contain("11.0.0");
+ }
+
+ [Fact]
+ public async Task Version_range_is_kept_as_written()
+ {
+ // Arrange
+ (tempDirectory / "build" / "_build.csproj").WriteAllText("""
+
+
+
+
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText();
+ buildCsproj.Should().Contain("""""");
+ buildCsproj.Should().Contain("""""");
+ }
+
+ [Fact]
+ public async Task Stale_version_override_is_pinned_to_the_current_fallout_version()
+ {
+ // Arrange
+ (tempDirectory / "build" / "_build.csproj").WriteAllText("""
+
+
+
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ summary.EditCount.Should().Be(1);
+ (tempDirectory / "build" / "_build.csproj").ReadAllText()
+ .Should().Contain("""""");
+ }
+
+ [Fact]
+ public async Task Version_attribute_before_include_is_also_pinned()
+ {
+ // Arrange
+ (tempDirectory / "build" / "_build.csproj").WriteAllText("""
+
+
+
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ summary.EditCount.Should().Be(1);
+ (tempDirectory / "build" / "_build.csproj").ReadAllText()
+ .Should().Contain("""""");
+ }
+
+ [Fact]
+ public async Task Package_download_keeps_its_exact_version_brackets()
+ {
+ // Arrange
+ (tempDirectory / "build" / "_build.csproj").WriteAllText("""
+
+
+
+
+
+ """, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ (tempDirectory / "build" / "_build.csproj").ReadAllText()
+ .Should().Contain("""""");
+ }
+
+ [Fact]
+ public async Task Already_migrated_package_pins_are_kept()
+ {
+ // Arrange
+ const string input = """
+
+
+
+
+
+ """;
+ (tempDirectory / "Directory.Packages.props").WriteAllText(input, eofLineBreak: false);
+
+ // Act
+ await new RewriteCsprojsStep().ExecuteAsync(context, summary);
+
+ // Assert
+ summary.EditCount.Should().Be(0);
+ (tempDirectory / "Directory.Packages.props").ReadAllText().Should().Be(input);
+ }
}