Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/Fallout.Migrate/Common/MigrationFileOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static IEnumerable<AbsolutePath> EnumerateFiles(AbsolutePath rootDirector
{
foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue))
{
if (IsIgnored(file))
if (IsIgnored(rootDirectory, file))
{
continue;
}
Expand Down Expand Up @@ -88,13 +88,21 @@ public static string RelativePath(AbsolutePath rootDirectory, AbsolutePath absol

/// <summary>
/// Returns <c>true</c> if <paramref name="path"/> sits under a <c>bin/</c>, <c>obj/</c>, or
/// <c>.git/</c> directory and should be skipped by <see cref="EnumerateFiles"/>.
/// <c>.git/</c> directory below <paramref name="rootDirectory"/> and should be skipped by
/// <see cref="EnumerateFiles"/>. Only the path below the root counts, so a repository cloned
/// under a folder named <c>bin</c> or <c>obj</c> is still migrated.
/// </summary>
private static bool IsIgnored(AbsolutePath path)
private static bool IsIgnored(AbsolutePath rootDirectory, AbsolutePath path)
Comment thread
ANcpLua marked this conversation as resolved.
{
string text = path;
return text.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|| text.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|| text.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}", StringComparison.Ordinal);
var segments = RelativePath(rootDirectory, path).Split('/');
for (var i = 0; i < segments.Length - 1; i++)
{
if (segments[i] is "bin" or "obj" or ".git")
{
return true;
}
}

return false;
}
}
93 changes: 90 additions & 3 deletions src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ internal sealed class BumpDotNetVersionStep : IMigrationStep
@"(?<=""sdk""\s*:\s*\{[^}]*?""version""\s*:\s*"")[^""]+",
RegexOptions.Compiled | RegexOptions.Singleline);

/// <summary>
/// The roll-forward policy added to <c>global.json</c> when the SDK is pinned and no policy is set.
/// Without one, the default <c>patch</c> policy needs a 10.0.1xx SDK, so a machine with only a
/// later feature band (for example 10.0.4xx) fails every <c>dotnet</c> command.
/// Fallout's own <c>global.json</c> uses <c>latestMinor</c>. <c>latestFeature</c> is used here
/// because it allows a later feature band but never a later minor version, so it changes less
/// in the migrated repository.
/// </summary>
private const string RollForward = "latestFeature";
Comment thread
ANcpLua marked this conversation as resolved.

/// <summary>
/// The roll-forward policies that accept only an SDK from the pinned feature band (10.0.1xx).
/// An existing one is kept, but the user gets a warning.
/// </summary>
private static readonly string[] pinnedFeatureBandPolicies = ["patch", "latestPatch", "disable"];

/// <summary>
/// Matches a <c>rollForward</c> property inside <c>global.json</c>'s <c>sdk</c> object, and
/// captures its value.
/// </summary>
private static readonly Regex sdkRollForwardPattern = new(
@"""sdk""\s*:\s*\{[^}]*""rollForward""(?:\s*:\s*""(?<value>[^""]*)"")?",
RegexOptions.Compiled | RegexOptions.Singleline);

/// <inheritdoc />
public Task ExecuteAsync(MigrationContext context, Summary summary)
{
Expand All @@ -60,12 +84,42 @@ public Task ExecuteAsync(MigrationContext context, Summary summary)

foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "global.json"))
{
MigrationFileOperations.ApplyRewrite(context, path, BumpSdkVersion, summary);
MigrationFileOperations.ApplyRewrite(
context,
path,
original =>
{
var result = BumpSdkVersion(original);
if (result.EditCount > 0 && KeptPinnedFeatureBandPolicy(result.Content) is { } policy)
{
summary.Warnings.Add(
$"{MigrationFileOperations.RelativePath(context.RootDirectory, path)} keeps \"rollForward\": \"{policy}\". " +
$"With the new SDK pin {SdkVersion}, this policy needs a 10.0.1xx SDK, so a machine with only " +
"a later feature band (for example 10.0.4xx) fails every dotnet command. " +
$"Change it to \"{RollForward}\" unless you need exactly that feature band.");
}

return result;
},
summary);
}

return Task.CompletedTask;
}

/// <summary>
/// Returns the <c>rollForward</c> value of <paramref name="content"/> when it is one of
/// <see cref="pinnedFeatureBandPolicies"/>, otherwise <c>null</c>.
/// </summary>
/// <param name="content">The <c>global.json</c> content after the SDK pin was bumped.</param>
private static string KeptPinnedFeatureBandPolicy(string content)
{
var value = sdkRollForwardPattern.Match(content).Groups["value"];
return value.Success && Array.Exists(pinnedFeatureBandPolicies, p => p.Equals(value.Value, StringComparison.OrdinalIgnoreCase))
? value.Value
: null;
}

/// <summary>
/// Rewrites a build project's <c>TargetFramework</c> element to <see cref="TargetFramework"/>
/// when its current moniker is behind <see cref="MinimumSupportedMajor"/>.
Expand All @@ -91,7 +145,8 @@ private static RewriteResult BumpTargetFramework(string original)

/// <summary>
/// Rewrites <c>global.json</c>'s <c>sdk.version</c> to <see cref="SdkVersion"/> only when the
/// current version is behind <see cref="minimumSupportedSdkVersion"/>.
/// current version is behind <see cref="minimumSupportedSdkVersion"/>, and adds
/// <see cref="RollForward"/> when the <c>sdk</c> object has no <c>rollForward</c> yet.
/// </summary>
/// <param name="original">The original <c>global.json</c> content.</param>
/// <returns>The rewritten content and the number of edits made.</returns>
Expand All @@ -104,7 +159,39 @@ public static RewriteResult BumpSdkVersion(string original)
}

string content = sdkVersionPattern.Replace(original, SdkVersion, count: 1);
return new RewriteResult(content, 1);
if (sdkRollForwardPattern.IsMatch(content))
Comment thread
ANcpLua marked this conversation as resolved.
{
return new RewriteResult(content, 1);
}

// Insert after the closing quote of the new version value.
int insertAt = match.Index + SdkVersion.Length + 1;
content = content.Insert(insertAt, $",{PropertySeparator(original, match.Index)}\"rollForward\": \"{RollForward}\"");
return new RewriteResult(content, 2);
}

/// <summary>
/// Returns the text to put before a new property next to the <c>version</c> property: a line
/// break plus the same indentation when <c>version</c> starts its own line, otherwise a space.
/// </summary>
/// <param name="original">The original <c>global.json</c> content.</param>
/// <param name="versionValueIndex">The index of the <c>sdk.version</c> value.</param>
private static string PropertySeparator(string original, int versionValueIndex)
{
int lineStart = original.LastIndexOf('\n', versionValueIndex) + 1;
int keyStart = lineStart;
while (original[keyStart] is ' ' or '\t')
{
keyStart++;
}

if (string.CompareOrdinal(original, keyStart, "\"version\"", 0, "\"version\"".Length) != 0)
{
return " ";
}

string lineBreak = original.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
return lineBreak + original[lineStart..keyStart];
}

/// <summary>
Expand Down
10 changes: 7 additions & 3 deletions src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@ internal sealed class RewriteCsFilesStep : IMigrationStep
// the canonical v11 namespace in one edit instead of the now-dead
// `Fallout.Common.ProjectModel`. Matching both source prefixes also fixes
// already-partially-migrated code. Mirrors the codefix mapping from #253.
// The leading `(?<![\w.])` lookbehind keeps the namespace inside another dotted name
// (`Acme.Nuke.Common.ProjectModel`) as it is, the same as in namespacePrefix below.
private static readonly Regex projectModelNamespace =
Comment thread
ANcpLua marked this conversation as resolved.
new(@"\b(?:Nuke|Fallout)\.Common\.ProjectModel\b", RegexOptions.Compiled);
new(@"(?<![\w.])(?:Nuke|Fallout)\.Common\.ProjectModel\b", RegexOptions.Compiled);

// Anchored prefix swap: `\bNuke\.` → `Fallout.`. Covers using directives,
// Anchored prefix swap: `Nuke.` → `Fallout.`. Covers using directives,
// attribute references, qualified type names, namespace declarations.
// The leading `(?<![\w.])` lookbehind keeps a `Nuke` segment inside another
// dotted name (`Acme.Nuke.Tools`) as it is.
// The trailing `(?=[A-Z])` lookahead avoids matching `Nuke.json` filenames
// or other lowercase tails the prefix audit deliberately preserved.
private static readonly Regex namespacePrefix =
new(@"\bNuke\.(?=[A-Z])", RegexOptions.Compiled);
new(@"(?<![\w.])Nuke\.(?=[A-Z])", RegexOptions.Compiled);

// Bare type renames done in the Fallout rebrand (#59).
private static readonly Regex nukeBuildType = new(@"\bNukeBuild\b", RegexOptions.Compiled);
Expand Down
83 changes: 83 additions & 0 deletions tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,89 @@ public async Task Old_sdk_version_in_global_json_is_bumped()
summary.EditCount.Should().Be(1);
}

[Fact]
public async Task Bumped_sdk_version_gets_a_roll_forward_policy_when_none_is_set()
{
// Arrange
var globalJson = tempDirectory / "global.json";
globalJson.WriteAllText(
"""
{
"sdk": {
"version": "8.0.100"
}
}
""");

// Act
await new BumpDotNetVersionStep().ExecuteAsync(context, summary);

// Assert
globalJson.ReadAllText().Should().Contain(
"""
"version": "10.0.100",
"rollForward": "latestFeature"
}
""");
summary.FilesChanged.Should().Be(1);
summary.EditCount.Should().Be(2);
}

[Theory]
[InlineData("patch")]
[InlineData("latestPatch")]
[InlineData("disable")]
public async Task Kept_roll_forward_policy_that_needs_the_pinned_feature_band_gives_a_warning(string policy)
{
// Arrange
var globalJson = tempDirectory / "global.json";
globalJson.WriteAllText(
$$"""
{
"sdk": {
"version": "8.0.100",
"rollForward": "{{policy}}"
}
}
""");

// Act
await new BumpDotNetVersionStep().ExecuteAsync(context, summary);

// Assert
var content = globalJson.ReadAllText();
content.Should().Contain(@"""version"": ""10.0.100""");
content.Should().Contain($@"""rollForward"": ""{policy}""");
content.Should().NotContain("latestFeature");
summary.EditCount.Should().Be(1);
summary.Warnings.Should().ContainSingle()
.Which.Should().Contain($@"""rollForward"": ""{policy}""").And.Contain("latestFeature");
}

[Fact]
public async Task Kept_roll_forward_policy_that_allows_a_later_feature_band_gives_no_warning()
{
// Arrange
var globalJson = tempDirectory / "global.json";
globalJson.WriteAllText(
"""
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestMajor"
}
}
""");

// Act
await new BumpDotNetVersionStep().ExecuteAsync(context, summary);

// Assert
globalJson.ReadAllText().Should().Contain(@"""rollForward"": ""latestMajor""");
summary.EditCount.Should().Be(1);
summary.Warnings.Should().BeEmpty();
}

[Fact]
public async Task Already_pinned_sdk_version_is_left_unchanged()
{
Expand Down
77 changes: 77 additions & 0 deletions tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,83 @@ public async Task Qualified_nuke_type_references_are_rewritten_to_fallout()
buildCs.Should().Be("var x = new Fallout.Common.Tools.DotNet.DotNetTasks();");
}

[Fact]
public async Task Nuke_inside_another_dotted_name_is_left_alone()
{
// Arrange
const string input = """
using Acme.Nuke.Tools;
using Nuke.Common;
""";

(tempDirectory / "Build.cs").WriteAllText(input, eofLineBreak: false);

// Act
await new RewriteCsFilesStep().ExecuteAsync(context, summary);

// Assert
summary.EditCount.Should().Be(1);
var buildCs = (tempDirectory / "Build.cs").ReadAllText();
buildCs.Should().Contain("using Acme.Nuke.Tools;");
buildCs.Should().Contain("using Fallout.Common;");
}

[Fact]
public async Task Project_model_namespace_inside_another_dotted_name_is_left_alone()
{
// Arrange
const string input = """
using Acme.Nuke.Common.ProjectModel;
using Nuke.Common.ProjectModel;
""";

(tempDirectory / "Build.cs").WriteAllText(input, eofLineBreak: false);

// Act
await new RewriteCsFilesStep().ExecuteAsync(context, summary);

// Assert
summary.EditCount.Should().Be(1);
var buildCs = (tempDirectory / "Build.cs").ReadAllText();
buildCs.Should().Contain("using Acme.Nuke.Common.ProjectModel;");
buildCs.Should().Contain("using Fallout.Solutions;");
}

[Theory]
[InlineData("bin")]
[InlineData("obj")]
[InlineData(".git")]
public async Task Files_in_an_ignored_folder_below_the_root_are_skipped(string folder)
{
// Arrange
const string input = "using Nuke.Common;";
var ignoredFile = tempDirectory / "src" / folder / "Gen.cs";
ignoredFile.WriteAllText(input, eofLineBreak: false);

// Act
await new RewriteCsFilesStep().ExecuteAsync(context, summary);

// Assert
summary.EditCount.Should().Be(0);
ignoredFile.ReadAllText().Should().Be(input);
}

[Fact]
public async Task Files_are_rewritten_when_the_repository_sits_under_a_bin_folder()
{
// Arrange
var repository = tempDirectory / "bin" / "repo";
(repository / "Build.cs").WriteAllText("using Nuke.Common;");
var repositoryContext = new MigrationContext(repository, dryRun: false, TextWriter.Null);

// Act
await new RewriteCsFilesStep().ExecuteAsync(repositoryContext, summary);

// Assert
summary.EditCount.Should().Be(1);
(repository / "Build.cs").ReadAllText().Should().Contain("using Fallout.Common;");
}

[Fact]
public async Task NukeBuild_base_type_is_renamed_to_FalloutBuild()
{
Expand Down
Loading