Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/Fallout.Migrate/Common/Summary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ internal sealed class Summary
/// <summary>The number of directories renamed (currently just <c>.nuke/</c> → <c>.fallout/</c>).</summary>
public int DirectoriesRenamed { get; set; }

/// <summary>
/// Set when the user declined the confirmation prompt in <see cref="Fallout.Migrate.Steps.ConfirmMigrationStep"/>.
/// <see cref="Migration.RunAsync"/> stops executing further steps once this is set.
/// </summary>
public bool Cancelled { get; set; }

/// <summary>Human-readable warnings about conditions the migration could not resolve automatically.</summary>
public List<string> Warnings { get; } = new();
}
49 changes: 49 additions & 0 deletions src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs
Comment thread
dennisdoomen marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Globalization;
using System.Text.RegularExpressions;

namespace Fallout.Migrate.Common;

/// <summary>
/// Shared parsing helpers for modern, dotted .NET target framework monikers (e.g. <c>net10.0</c>),
/// used by migration steps that compare a project's current TFM against a minimum.
/// </summary>
internal static class TargetFrameworkMonikers
{
/// <summary>
/// Matches a modern, dotted .NET moniker such as <c>net8.0</c> or <c>net10.0</c>.
/// </summary>
private static readonly Regex modernMonikerPattern = new(
@"^net(?<major>\d+)\.\d+$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>
/// Extracts the major version segment from a modern TFM moniker such as <c>net10.0</c>.
/// </summary>
/// <param name="moniker">A modern, dotted target framework moniker.</param>
/// <returns>The parsed major version.</returns>
public static int ExtractMajor(string moniker)
{
return int.Parse(
modernMonikerPattern.Match(moniker).Groups["major"].Value,
CultureInfo.InvariantCulture);
}

/// <summary>
/// Returns <c>true</c> when <paramref name="moniker"/> targets an older .NET than
/// <paramref name="minimumMajor"/> — including non-modern monikers (.NET Framework, .NET
/// Standard, out-of-support <c>netcoreapp*</c>) which never satisfy any minimum.
/// </summary>
/// <param name="moniker">A single target framework moniker, e.g. <c>net8.0</c>.</param>
/// <param name="minimumMajor">The minimum supported major version.</param>
public static bool IsOlderThanMinimumSupported(string moniker, int minimumMajor)
{
Match match = modernMonikerPattern.Match(moniker);
if (!match.Success)
{
return true;
}

int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture);
return major < minimumMajor;
}
}
1 change: 1 addition & 0 deletions src/Fallout.Migrate/Fallout.Migrate.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<ToolCommandName>fallout-migrate</ToolCommandName>
<Description>CLI that migrates an existing NUKE consumer repo to Fallout in one command.</Description>
<PackageTags>build automation continuous-integration tools orchestration migration</PackageTags>
<AssemblyVersion>10.0.0</AssemblyVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
5 changes: 5 additions & 0 deletions src/Fallout.Migrate/MigrateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ public override async Task<int> ExecuteAsync(CommandContext context, MigrateSett
var migration = new Migration(rootDirectory, settings.DryRun, Console.Out);
Summary summary = await migration.RunAsync();

if (summary.Cancelled)
{
return 1;
}

PrintSummary(summary, settings.DryRun);
return 0;
}
Expand Down
10 changes: 9 additions & 1 deletion src/Fallout.Migrate/Migration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri
private static readonly IReadOnlyList<IMigrationStep> steps =
[
new ResolveFalloutVersionStep(),
new VerifyBuildTargetFrameworkStep(),
new ConfirmMigrationStep(),
new RewriteCsprojsStep(),
new BumpDotNetVersionStep(),
new RewriteCsFilesStep(),
new RewriteBootstrapScriptsStep(),
new RenameNukeDirectoryStep()
];

/// <summary>
/// Runs every step in <see cref="steps"/> against a fresh <see cref="MigrationContext"/>.
/// Runs every step in <see cref="steps"/> against a fresh <see cref="MigrationContext"/>, stopping
/// early if <see cref="ConfirmMigrationStep"/> (or any later step) sets <see cref="Summary.Cancelled"/>.
/// </summary>
/// <returns>A <see cref="Summary"/> of the files changed, edits made, and any warnings.</returns>
public async Task<Summary> RunAsync()
Expand All @@ -42,6 +46,10 @@ public async Task<Summary> RunAsync()
foreach (var step in steps)
{
await step.ExecuteAsync(context, summary);
if (summary.Cancelled)
{
break;
}
}

return summary;
Expand Down
125 changes: 125 additions & 0 deletions src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Fallout.Migrate.Common;

namespace Fallout.Migrate.Steps;

/// <summary>
/// Bumps the repo's build orchestrator project (<c>_build.csproj</c>) to target
/// <see cref="TargetFramework"/> and pins <c>global.json</c>'s SDK version to
/// <see cref="SdkVersion"/> — but only when what's there is behind those minimums. Already
/// up-to-date or newer values (e.g. a build project already on <c>net11.0</c>) are left alone.
/// </summary>
internal sealed class BumpDotNetVersionStep : IMigrationStep
{
/// <summary>
/// The .NET target framework moniker to bump <c>_build.csproj</c> to when it's behind.
/// </summary>
private const string TargetFramework = "net10.0";

/// <summary>
/// The .NET SDK version to pin <c>global.json</c> to when it's behind.
/// </summary>
private const string SdkVersion = "10.0.100";
Comment thread
dennisdoomen marked this conversation as resolved.

/// <summary>
/// The minimum .NET SDK version. Versions at or above this aren't touched.
/// </summary>
private static readonly Version minimumSupportedSdkVersion = new(10, 0, 100);

/// <summary>
/// The minimum .NET target framework major version, derived from <see cref="TargetFramework"/>.
/// Monikers at or above this aren't touched. Shared with
/// <see cref="VerifyBuildTargetFrameworkStep"/> so the minimum is only defined once.
/// </summary>
internal static readonly int MinimumSupportedMajor = TargetFrameworkMonikers.ExtractMajor(TargetFramework);

/// <summary>
/// Matches the <c>TargetFramework</c> element's raw value. Build projects don't multi-target,
/// so <c>TargetFrameworks</c> (plural) is intentionally not matched here.
/// </summary>
private static readonly Regex targetFrameworkElementPattern = new(
@"<TargetFramework>(?<value>[^<]+)</TargetFramework>",
RegexOptions.Compiled);

/// <summary>
/// Matches the value of <c>global.json</c>'s <c>sdk.version</c> property.
/// </summary>
private static readonly Regex sdkVersionPattern = new(
@"(?<=""sdk""\s*:\s*\{[^}]*?""version""\s*:\s*"")[^""]+",
RegexOptions.Compiled | RegexOptions.Singleline);

/// <inheritdoc />
public Task ExecuteAsync(MigrationContext context, Summary summary)
{
foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj"))
{
MigrationFileOperations.ApplyRewrite(context, path, BumpTargetFramework, summary);
}

foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "global.json"))
{
MigrationFileOperations.ApplyRewrite(context, path, BumpSdkVersion, summary);
}

return Task.CompletedTask;
}

/// <summary>
/// Rewrites a build project's <c>TargetFramework</c> element to <see cref="TargetFramework"/>
/// when its current moniker is behind <see cref="MinimumSupportedMajor"/>.
/// </summary>
/// <param name="original">The original <c>_build.csproj</c> content.</param>
/// <returns>The rewritten content and the number of edits made.</returns>
public static RewriteResult BumpTargetFramework(string original)
{
Match match = targetFrameworkElementPattern.Match(original);
if (!match.Success ||
!TargetFrameworkMonikers.IsOlderThanMinimumSupported(match.Groups["value"].Value, MinimumSupportedMajor))
{
return new RewriteResult(original, 0);
}

string content = targetFrameworkElementPattern.Replace(
original,
$"<TargetFramework>{TargetFramework}</TargetFramework>",
count: 1);

return new RewriteResult(content, 1);
}

/// <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"/>.
/// </summary>
/// <param name="original">The original <c>global.json</c> content.</param>
/// <returns>The rewritten content and the number of edits made.</returns>
public static RewriteResult BumpSdkVersion(string original)
{
Match match = sdkVersionPattern.Match(original);
if (!match.Success || !IsOlderThanMinimumSupportedSdk(match.Value))
{
return new RewriteResult(original, 0);
}

string content = sdkVersionPattern.Replace(original, SdkVersion, count: 1);
return new RewriteResult(content, 1);
}

/// <summary>
/// Returns <c>true</c> when <paramref name="version"/> is behind
/// <see cref="minimumSupportedSdkVersion"/>, or can't be parsed as a version at all.
/// </summary>
/// <param name="version">The <c>sdk.version</c> value from <c>global.json</c>.</param>
private static bool IsOlderThanMinimumSupportedSdk(string version)
{
Version parsed;
if (!Version.TryParse(version, out parsed))
{
return true;
}

return parsed < minimumSupportedSdkVersion;
}
}
43 changes: 43 additions & 0 deletions src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Fallout.Migrate.Common;
using Spectre.Console;

namespace Fallout.Migrate.Steps;

/// <summary>
/// Asks the user to confirm before any subsequent step writes to disk. Runs last among the
/// read-only/advisory steps, immediately before <see cref="RewriteCsprojsStep"/> — the first step
/// in <see cref="Migration.steps"/> that mutates files. Sets <see cref="Summary.Cancelled"/> when
/// the user declines, which <see cref="Migration.RunAsync"/> checks to stop early.
/// </summary>
internal sealed class ConfirmMigrationStep : IMigrationStep
{
/// <inheritdoc />
public async Task ExecuteAsync(MigrationContext context, Summary summary)
{
if (context.DryRun)
{
// Nothing will be written; no point confirming.
return;
}

if (Console.IsInputRedirected)
{
// Non-interactive (CI, piped input, automated tests) — nothing to prompt against, so
// proceed as if confirmed.
return;
}

var prompt = new ConfirmationPrompt(
$"This will modify files under [blue]{context.RootDirectory}[/]. Continue?");
Comment thread
dennisdoomen marked this conversation as resolved.

bool proceed = await prompt.ShowAsync(AnsiConsole.Console, CancellationToken.None);
if (!proceed)
{
summary.Cancelled = true;
AnsiConsole.MarkupLine("[yellow]Migration cancelled — no files were changed.[/]");
}
}
}
98 changes: 0 additions & 98 deletions src/Fallout.Migrate/Steps/CsprojRewriter.cs

This file was deleted.

Loading
Loading