diff --git a/src/Fallout.Migrate/Common/IMigrationStep.cs b/src/Fallout.Migrate/Common/IMigrationStep.cs
new file mode 100644
index 000000000..bf98cc9a5
--- /dev/null
+++ b/src/Fallout.Migrate/Common/IMigrationStep.cs
@@ -0,0 +1,18 @@
+using System.Threading.Tasks;
+
+namespace Fallout.Migrate.Common;
+
+///
+/// One unit of work performed against a repo during fallout-migrate. Add a new step by
+/// implementing this interface and registering it in 's step list.
+///
+internal interface IMigrationStep
+{
+ ///
+ /// Performs this step's work against , recording the outcome in
+ /// .
+ ///
+ /// The current migration context.
+ /// The summary to update with files changed, edits made, or warnings.
+ Task ExecuteAsync(MigrationContext context, Summary summary);
+}
diff --git a/src/Fallout.Migrate/Common/MigrationContext.cs b/src/Fallout.Migrate/Common/MigrationContext.cs
new file mode 100644
index 000000000..dab38e49d
--- /dev/null
+++ b/src/Fallout.Migrate/Common/MigrationContext.cs
@@ -0,0 +1,29 @@
+using System;
+using System.IO;
+using Fallout.Common.IO;
+
+namespace Fallout.Migrate.Common;
+
+///
+/// Plain data carried between and each .
+/// Holds no behavior itself; see for the shared
+/// file-walking / rewrite-application helpers steps call into.
+///
+internal sealed class MigrationContext(AbsolutePath rootDirectory, bool dryRun, TextWriter log)
+{
+ /// The repository root being migrated.
+ public AbsolutePath RootDirectory { get; } = rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory));
+
+ /// When true, steps must report intended changes without writing them.
+ public bool DryRun { get; } = dryRun;
+
+ /// The writer steps use to report progress.
+ public TextWriter Log { get; } = log ?? throw new ArgumentNullException(nameof(log));
+
+ ///
+ /// The Fallout version to pin in rewritten package references.
+ /// Set by , which always runs first;
+ /// subsequent steps read it.
+ ///
+ public string FalloutVersion { get; internal set; }
+}
diff --git a/src/Fallout.Migrate/Common/MigrationFileOperations.cs b/src/Fallout.Migrate/Common/MigrationFileOperations.cs
new file mode 100644
index 000000000..a5ba5e44b
--- /dev/null
+++ b/src/Fallout.Migrate/Common/MigrationFileOperations.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Fallout.Common.IO;
+
+namespace Fallout.Migrate.Common;
+
+///
+/// Shared file-walking and rewrite-application helpers used by multiple
+/// implementations. Kept as static, dependency-free functions so steps stay small and independent.
+///
+internal static class MigrationFileOperations
+{
+ ///
+ /// Recursively enumerates files under matching
+ /// , skipping bin/, obj/, and .git/.
+ ///
+ /// The directory to search from.
+ /// A file-name glob pattern, e.g. *.csproj.
+ /// The matching, non-ignored files.
+ public static IEnumerable EnumerateFiles(AbsolutePath rootDirectory, string pattern)
+ {
+ foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue))
+ {
+ if (IsIgnored(file))
+ {
+ continue;
+ }
+
+ yield return file;
+ }
+ }
+
+ ///
+ /// Reads , applies , logs and records the edit,
+ /// and writes the result back unless is set.
+ ///
+ /// The current migration context.
+ /// The file to rewrite.
+ /// The rewrite function to apply to the file's content.
+ /// The summary to update with the outcome.
+ public static void ApplyRewrite(
+ MigrationContext context,
+ AbsolutePath path,
+ Func rewriter,
+ Summary summary)
+ {
+ string original;
+ try
+ {
+ original = path.ReadAllText();
+ }
+ catch (IOException ex)
+ {
+ summary.Warnings.Add($"could not read {RelativePath(context.RootDirectory, path)}: {ex.Message}");
+ return;
+ }
+
+ var result = rewriter(original);
+ if (result.EditCount == 0)
+ {
+ return;
+ }
+
+ context.Log.WriteLine(
+ $"edit {RelativePath(context.RootDirectory, path)} ({result.EditCount} change{(result.EditCount == 1 ? "" : "s")})");
+
+ summary.FilesChanged++;
+ summary.EditCount += result.EditCount;
+
+ if (!context.DryRun)
+ {
+ // eofLineBreak: false preserves today's exact byte-for-byte write behavior;
+ // AbsolutePath.WriteAllText's default normalizes trailing line endings, which
+ // would otherwise sneak unrelated diff noise into migrated consumer repos.
+ path.WriteAllText(result.Content, eofLineBreak: false);
+ }
+ }
+
+ ///
+ /// Formats as a path relative to ,
+ /// using forward slashes, for log output.
+ ///
+ /// The directory to make the path relative to.
+ /// The path to format.
+ public static string RelativePath(AbsolutePath rootDirectory, AbsolutePath absolute) =>
+ rootDirectory.GetUnixRelativePathTo(absolute);
+
+ ///
+ /// Returns true if sits under a bin/, obj/, or
+ /// .git/ directory and should be skipped by .
+ ///
+ private static bool IsIgnored(AbsolutePath path)
+ {
+ 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);
+ }
+}
diff --git a/src/Fallout.Migrate/Common/RewriteResult.cs b/src/Fallout.Migrate/Common/RewriteResult.cs
new file mode 100644
index 000000000..4c1de5717
--- /dev/null
+++ b/src/Fallout.Migrate/Common/RewriteResult.cs
@@ -0,0 +1,9 @@
+namespace Fallout.Migrate.Common;
+
+///
+/// The result of rewriting a file's content: the new and how many
+/// individual edits were made.
+///
+/// The rewritten file content.
+/// The number of edits applied; zero means the content is unchanged.
+internal readonly record struct RewriteResult(string Content, int EditCount);
diff --git a/src/Fallout.Migrate/Common/Summary.cs b/src/Fallout.Migrate/Common/Summary.cs
new file mode 100644
index 000000000..c554568d7
--- /dev/null
+++ b/src/Fallout.Migrate/Common/Summary.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+
+namespace Fallout.Migrate.Common;
+
+///
+/// Aggregate result of a migration run: how many files changed, how many edits were made, how many
+/// directories were renamed, and any warnings that need manual follow-up.
+///
+internal sealed class Summary
+{
+ /// The number of files that had at least one edit applied.
+ public int FilesChanged { get; set; }
+
+ /// The total number of individual edits made across all rewritten files.
+ public int EditCount { get; set; }
+
+ /// The number of directories renamed (currently just .nuke/ → .fallout/).
+ public int DirectoriesRenamed { get; set; }
+
+ /// Human-readable warnings about conditions the migration could not resolve automatically.
+ public List Warnings { get; } = new();
+}
diff --git a/src/Fallout.Migrate/MigrateCommand.cs b/src/Fallout.Migrate/MigrateCommand.cs
index 6338ca6a5..e7d2cbc2a 100644
--- a/src/Fallout.Migrate/MigrateCommand.cs
+++ b/src/Fallout.Migrate/MigrateCommand.cs
@@ -1,13 +1,19 @@
using System;
using System.ComponentModel;
using System.IO;
+using System.Threading.Tasks;
using Fallout.Common;
using Fallout.Common.IO;
+using Fallout.Migrate.Common;
using JetBrains.Annotations;
using Spectre.Console.Cli;
namespace Fallout.Migrate;
+///
+/// The fallout-migrate CLI command. See the below for the
+/// user-facing summary of what the migration does.
+///
[Description("""
Migrate a NUKE consumer repo to Fallout.
@@ -20,9 +26,15 @@ in build.cmd / build.ps1 / build.sh
- Prints a summary of files changed and warnings to address manually
""")]
[UsedImplicitly]
-internal sealed class MigrateCommand : Command
+internal sealed class MigrateCommand : AsyncCommand
{
- public override int Execute(CommandContext context, MigrateSettings settings)
+ ///
+ /// Resolves the repository root, runs the migration, and prints a summary.
+ ///
+ /// The Spectre.Console.Cli command context (unused).
+ /// The parsed for this invocation.
+ /// 0 on success, 1 if the repository root could not be resolved.
+ public override async Task ExecuteAsync(CommandContext context, MigrateSettings settings)
{
var rootDirectory = ResolveRootDirectory(settings.Path);
if (rootDirectory is null)
@@ -37,12 +49,18 @@ public override int Execute(CommandContext context, MigrateSettings settings)
PrintBanner(rootDirectory, settings.DryRun);
var migration = new Migration(rootDirectory, settings.DryRun, Console.Out);
- var summary = migration.Run();
+ Summary summary = await migration.RunAsync();
PrintSummary(summary, settings.DryRun);
return 0;
}
+ ///
+ /// Resolves the repository root to migrate: the explicit if given,
+ /// otherwise the nearest ancestor of the working directory that looks like a Fallout/NUKE build repo.
+ ///
+ /// The path argument passed on the command line, or null.
+ /// The resolved repository root, or null if none could be found.
private static AbsolutePath ResolveRootDirectory(string explicitArg)
{
if (explicitArg != null)
@@ -59,6 +77,11 @@ private static AbsolutePath ResolveRootDirectory(string explicitArg)
(current / "build.sh").FileExists());
}
+ ///
+ /// Prints the tool banner and, when applicable, the dry-run notice.
+ ///
+ /// The repository root being migrated.
+ /// Whether the migration is running in dry-run mode.
private static void PrintBanner(AbsolutePath rootDirectory, bool dryRun)
{
Console.WriteLine($"fallout-migrate — migrating: {rootDirectory}");
@@ -70,12 +93,18 @@ private static void PrintBanner(AbsolutePath rootDirectory, bool dryRun)
Console.WriteLine();
}
- private static void PrintSummary(Migration.Summary summary, bool dryRun)
+ ///
+ /// Prints the migration (counts, warnings, and next-steps guidance) to the console.
+ ///
+ /// The result of .
+ /// Whether the migration ran in dry-run mode.
+ private static void PrintSummary(Summary summary, bool dryRun)
{
Console.WriteLine();
Console.WriteLine($"Files changed: {summary.FilesChanged}");
Console.WriteLine($"Edits made: {summary.EditCount}");
Console.WriteLine($"Directories: {summary.DirectoriesRenamed} renamed");
+
if (summary.Warnings.Count > 0)
{
Console.WriteLine();
@@ -89,7 +118,7 @@ private static void PrintSummary(Migration.Summary summary, bool dryRun)
Console.WriteLine();
Console.WriteLine(dryRun
? "Dry-run complete. Re-run without --dry-run to apply changes."
- : "Migration complete. Verify the build: ./build.ps1 (or ./build.sh on unix)");
+ : "Migration complete. Verify the build: ./build.ps1 (or ./build.sh on UNIX)");
Console.WriteLine("Migration guide: https://fallout.build (see #37 for the full guide)");
}
diff --git a/src/Fallout.Migrate/MigrateSettings.cs b/src/Fallout.Migrate/MigrateSettings.cs
index fc62a524d..8790da1cc 100644
--- a/src/Fallout.Migrate/MigrateSettings.cs
+++ b/src/Fallout.Migrate/MigrateSettings.cs
@@ -4,14 +4,24 @@
namespace Fallout.Migrate;
+///
+/// Command-line arguments accepted by .
+///
[UsedImplicitly]
internal sealed class MigrateSettings : CommandSettings
{
+ ///
+ /// The repository root to migrate. When null, resolves it by
+ /// walking up from the working directory.
+ ///
[CommandArgument(0, "[path]")]
[Description("Repository root. Defaults to walking up from the working directory to find " +
"one (looking for build.cmd / build.ps1 / build.sh, .nuke/, or build/).")]
public string Path { get; init; }
+ ///
+ /// When true, reports the changes would make without writing them.
+ ///
[CommandOption("-n|--dry-run")]
[Description("Show what would change without writing.")]
public bool DryRun { get; init; }
diff --git a/src/Fallout.Migrate/Migration.cs b/src/Fallout.Migrate/Migration.cs
index cf26e57a9..d54d854e8 100644
--- a/src/Fallout.Migrate/Migration.cs
+++ b/src/Fallout.Migrate/Migration.cs
@@ -1,196 +1,49 @@
-using System;
using System.Collections.Generic;
using System.IO;
-using System.Linq;
-using System.Reflection;
+using System.Threading.Tasks;
using Fallout.Common.IO;
+using Fallout.Migrate.Common;
+using Fallout.Migrate.Steps;
namespace Fallout.Migrate;
-internal sealed class Migration
+///
+/// Orchestrates a migration run: builds a and executes the fixed,
+/// ordered list of s against it. Add a new step by implementing
+/// and appending it to .
+///
+/// The repository root to migrate.
+/// When true, reports intended changes without writing them.
+/// The writer steps use to report progress.
+internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWriter log)
{
- private readonly AbsolutePath rootDirectory;
- private readonly bool dryRun;
- private readonly TextWriter log;
-
- public Migration(AbsolutePath rootDirectory, bool dryRun, TextWriter log)
- {
- this.rootDirectory = rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory));
- this.dryRun = dryRun;
- this.log = log ?? throw new ArgumentNullException(nameof(log));
- }
-
- public Summary Run()
+ ///
+ /// The steps executed by , in order. must
+ /// run first, since later steps read from it.
+ ///
+ private static readonly IReadOnlyList steps =
+ [
+ new ResolveFalloutVersionStep(),
+ new RewriteCsprojsStep(),
+ new RewriteCsFilesStep(),
+ new RewriteBootstrapScriptsStep(),
+ new RenameNukeDirectoryStep()
+ ];
+
+ ///
+ /// Runs every step in against a fresh .
+ ///
+ /// A of the files changed, edits made, and any warnings.
+ public async Task RunAsync()
{
var summary = new Summary();
+ var context = new MigrationContext(rootDirectory, dryRun, log);
- RewriteCsprojs(summary);
- RewriteCsFiles(summary);
- RewriteBootstrapScripts(summary);
- RenameNukeDirectory(summary);
-
- return summary;
- }
-
- private void RewriteCsprojs(Summary summary)
- {
- var falloutVersion = ResolveFalloutVersion();
- foreach (var path in EnumerateFiles("*.csproj"))
- {
- ApplyRewrite(path, content => CsprojRewriter.Rewrite(content, falloutVersion), summary);
- }
- }
-
- // Pinned into migrated `` lines.
- // Uses the running migrate tool's own SemVer (Nerdbank.GitVersioning, set on
- // AssemblyInformationalVersion) so the migration output aligns with the tool the user
- // just installed. For dev/local builds without a `+` in InformationalVersion (i.e. no
- // build-metadata suffix), falls back to a known-published floor so we never emit a
- // bogus pin like Version="LOCAL". Inlined to keep Fallout.Migrate dependency-free.
- private static string ResolveFalloutVersion()
- {
- const string fallback = "10.3.49";
-
- var informational = typeof(Migration).Assembly
- .GetCustomAttribute()
- ?.InformationalVersion;
-
- if (string.IsNullOrEmpty(informational))
- {
- return fallback;
- }
-
- int plusIndex = informational.IndexOf('+');
- if (plusIndex == -1)
- {
- return fallback;
- }
-
- return informational[..plusIndex];
- }
-
- private void RewriteCsFiles(Summary summary)
- {
- foreach (var path in EnumerateFiles("*.cs"))
- {
- ApplyRewrite(path, CodeRewriter.Rewrite, summary);
- }
- }
-
- private void RewriteBootstrapScripts(Summary summary)
- {
- foreach (var name in new[]
- {
- "build.cmd",
- "build.ps1",
- "build.sh"
- })
- {
- var path = rootDirectory / name;
- if (path.FileExists())
- {
- ApplyRewrite(path, ScriptRewriter.Rewrite, summary);
- }
- }
- }
-
- private void RenameNukeDirectory(Summary summary)
- {
- var legacy = rootDirectory / ".nuke";
- var canonical = rootDirectory / ".fallout";
-
- if (!legacy.DirectoryExists())
- {
- return;
- }
-
- if (canonical.DirectoryExists())
- {
- summary.Warnings.Add(
- "Both .nuke/ and .fallout/ exist. Skipped rename; merge their contents manually.");
-
- return;
- }
-
- Log($"rename {RelativePath(legacy)} -> {RelativePath(canonical)}");
- if (!dryRun)
- {
- // We purposely use the .NET directory move as this is atomic. Fallout's own
- // Move/MoveDirectory moves them one by one.
- Directory.Move(legacy, canonical);
- }
-
- summary.DirectoriesRenamed++;
- }
-
- private IEnumerable EnumerateFiles(string pattern)
- {
- foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue))
- {
- if (IsIgnored(file))
- {
- continue;
- }
-
- yield return file;
- }
- }
-
- private static bool IsIgnored(AbsolutePath path)
- {
- 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);
- }
-
- private void ApplyRewrite(AbsolutePath path, Func rewriter, Summary summary)
- {
- string original;
- try
- {
- original = path.ReadAllText();
- }
- catch (IOException ex)
- {
- summary.Warnings.Add($"could not read {RelativePath(path)}: {ex.Message}");
- return;
- }
-
- var result = rewriter(original);
- if (result.EditCount == 0)
+ foreach (var step in steps)
{
- return;
+ await step.ExecuteAsync(context, summary);
}
- Log($"edit {RelativePath(path)} ({result.EditCount} change{(result.EditCount == 1 ? "" : "s")})");
- summary.FilesChanged++;
- summary.EditCount += result.EditCount;
-
- if (!dryRun)
- {
- // eofLineBreak: false preserves today's exact byte-for-byte write behavior;
- // AbsolutePath.WriteAllText's default normalizes trailing line endings, which
- // would otherwise sneak unrelated diff noise into migrated consumer repos.
- path.WriteAllText(result.Content, eofLineBreak: false);
- }
- }
-
- private string RelativePath(AbsolutePath absolute) =>
- rootDirectory.GetUnixRelativePathTo(absolute);
-
- private void Log(string line) => log.WriteLine(line);
-
- internal sealed class Summary
- {
- public int FilesChanged { get; set; }
-
- public int EditCount { get; set; }
-
- public int DirectoriesRenamed { get; set; }
-
- public List Warnings { get; } = new();
+ return summary;
}
}
-
-internal readonly record struct RewriteResult(string Content, int EditCount);
diff --git a/src/Fallout.Migrate/Program.cs b/src/Fallout.Migrate/Program.cs
index 0a0075566..0d73df98b 100644
--- a/src/Fallout.Migrate/Program.cs
+++ b/src/Fallout.Migrate/Program.cs
@@ -1,10 +1,19 @@
+using System.Threading.Tasks;
using Spectre.Console.Cli;
namespace Fallout.Migrate;
+///
+/// Entry point for the fallout-migrate CLI tool.
+///
public static class Program
{
- public static int Main(string[] args)
+ ///
+ /// Configures and runs the as a single-command Spectre.Console.Cli app.
+ ///
+ /// The command-line arguments passed to the tool.
+ /// The process exit code.
+ public static async Task Main(string[] args)
{
var app = new CommandApp();
app.Configure(config =>
@@ -14,6 +23,6 @@ public static int Main(string[] args)
config.AddExample("path/to/repo");
});
- return app.Run(args);
+ return await app.RunAsync(args);
}
}
diff --git a/src/Fallout.Migrate/CodeRewriter.cs b/src/Fallout.Migrate/Steps/CodeRewriter.cs
similarity index 65%
rename from src/Fallout.Migrate/CodeRewriter.cs
rename to src/Fallout.Migrate/Steps/CodeRewriter.cs
index c32c6a7c2..d90858e38 100644
--- a/src/Fallout.Migrate/CodeRewriter.cs
+++ b/src/Fallout.Migrate/Steps/CodeRewriter.cs
@@ -1,7 +1,13 @@
using System.Text.RegularExpressions;
+using Fallout.Migrate.Common;
-namespace Fallout.Migrate;
+namespace Fallout.Migrate.Steps;
+///
+/// Rewrites .cs files: Nuke.* namespace prefixes become Fallout., and the bare
+/// NukeBuild/INukeBuild types become FalloutBuild/IFalloutBuild.
+/// Driven by .
+///
internal static class CodeRewriter
{
// Anchored prefix swap: `\bNuke\.` → `Fallout.`. Covers using directives,
@@ -15,6 +21,12 @@ internal static class CodeRewriter
private static readonly Regex nukeBuildType = new(@"\bNukeBuild\b", RegexOptions.Compiled);
private static readonly Regex iNukeBuildType = new(@"\bINukeBuild\b", RegexOptions.Compiled);
+ ///
+ /// Rewrites C# source, replacing Nuke.* references and the
+ /// bare NUKE build types with their Fallout equivalents.
+ ///
+ /// The original .cs file content.
+ /// The rewritten content and the number of edits made.
public static RewriteResult Rewrite(string original)
{
var edits = 0;
diff --git a/src/Fallout.Migrate/CsprojRewriter.cs b/src/Fallout.Migrate/Steps/CsprojRewriter.cs
similarity index 80%
rename from src/Fallout.Migrate/CsprojRewriter.cs
rename to src/Fallout.Migrate/Steps/CsprojRewriter.cs
index 6ece15252..15ddae94f 100644
--- a/src/Fallout.Migrate/CsprojRewriter.cs
+++ b/src/Fallout.Migrate/Steps/CsprojRewriter.cs
@@ -1,7 +1,14 @@
using System.Text.RegularExpressions;
+using Fallout.Migrate.Common;
-namespace Fallout.Migrate;
+namespace Fallout.Migrate.Steps;
+///
+/// Rewrites .csproj files: 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. Driven by .
+///
internal static class CsprojRewriter
{
// Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X
@@ -43,6 +50,13 @@ internal static class CsprojRewriter
@"^[ \t]*\s*\r?\n?",
RegexOptions.Compiled | RegexOptions.Multiline);
+ ///
+ /// 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 rewritten content and the number of edits made.
public static RewriteResult Rewrite(string original, string falloutVersion)
{
var edits = 0;
diff --git a/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs b/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs
new file mode 100644
index 000000000..a473895ea
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs
@@ -0,0 +1,46 @@
+using System.IO;
+using System.Threading.Tasks;
+using Fallout.Common.IO;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Renames the repository's .nuke/ directory to .fallout/, or records a warning if
+/// both already exist and need a manual merge.
+///
+internal sealed class RenameNukeDirectoryStep : IMigrationStep
+{
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ var legacy = context.RootDirectory / ".nuke";
+ var canonical = context.RootDirectory / ".fallout";
+
+ if (!legacy.DirectoryExists())
+ {
+ return Task.CompletedTask;
+ }
+
+ if (canonical.DirectoryExists())
+ {
+ summary.Warnings.Add(
+ "Both .nuke/ and .fallout/ exist. Skipped rename; merge their contents manually.");
+
+ return Task.CompletedTask;
+ }
+
+ context.Log.WriteLine(
+ $"rename {MigrationFileOperations.RelativePath(context.RootDirectory, legacy)} -> {MigrationFileOperations.RelativePath(context.RootDirectory, canonical)}");
+
+ if (!context.DryRun)
+ {
+ // We purposely use the .NET directory move as this is atomic. Fallout's own
+ // Move/MoveDirectory moves them one by one.
+ Directory.Move(legacy, canonical);
+ }
+
+ summary.DirectoriesRenamed++;
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs
new file mode 100644
index 000000000..d51c26a0c
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs
@@ -0,0 +1,59 @@
+using System.Reflection;
+using System.Threading.Tasks;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+// Pinned into migrated `` lines.
+
+// Uses the running migrate tool's own SemVer (Nerdbank.GitVersioning, set on
+
+// AssemblyInformationalVersion) so the migration output aligns with the tool the user
+
+// just installed. For dev/local builds without a `+` in InformationalVersion (i.e. no
+
+// build-metadata suffix), falls back to a known-published floor so we never emit a
+
+// bogus pin like Version="LOCAL". Inlined to keep Fallout.Migrate dependency-free.
+///
+/// Resolves the running tool's own Fallout version and stores it on .
+/// Must run first in : reads the
+/// resolved version to pin rewritten package references.
+///
+internal sealed class ResolveFalloutVersionStep : IMigrationStep
+{
+ /// The version to fall back to when the assembly carries no build-metadata suffix.
+ private const string Fallback = "10.3.49";
+
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ context.FalloutVersion = Resolve();
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Reads the tool assembly's and strips the
+ /// build-metadata suffix, falling back to when none is present.
+ ///
+ /// The Fallout version to pin into rewritten package references.
+ private static string Resolve()
+ {
+ var informational = typeof(ResolveFalloutVersionStep).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+
+ if (string.IsNullOrEmpty(informational))
+ {
+ return Fallback;
+ }
+
+ int plusIndex = informational.IndexOf('+');
+ if (plusIndex == -1)
+ {
+ return Fallback;
+ }
+
+ return informational[..plusIndex];
+ }
+}
diff --git a/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs b/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs
new file mode 100644
index 000000000..1e1fb5dfe
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs
@@ -0,0 +1,32 @@
+using System.Threading.Tasks;
+using Fallout.Common.IO;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Rewrites build.cmd, build.ps1, and build.sh at the repository root, when
+/// present, via .
+///
+internal sealed class RewriteBootstrapScriptsStep : IMigrationStep
+{
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ foreach (var name in new[]
+ {
+ "build.cmd",
+ "build.ps1",
+ "build.sh"
+ })
+ {
+ var path = context.RootDirectory / name;
+ if (path.FileExists())
+ {
+ MigrationFileOperations.ApplyRewrite(context, path, ScriptRewriter.Rewrite, summary);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs
new file mode 100644
index 000000000..235e35edc
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs
@@ -0,0 +1,21 @@
+using System.Threading.Tasks;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Rewrites every *.cs file under the repository root via .
+///
+internal sealed class RewriteCsFilesStep : IMigrationStep
+{
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.cs"))
+ {
+ MigrationFileOperations.ApplyRewrite(context, path, CodeRewriter.Rewrite, summary);
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
new file mode 100644
index 000000000..aed7ea78b
--- /dev/null
+++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs
@@ -0,0 +1,25 @@
+using System.Threading.Tasks;
+using Fallout.Migrate.Common;
+
+namespace Fallout.Migrate.Steps;
+
+///
+/// Rewrites every *.csproj file under the repository root via .
+///
+internal sealed class RewriteCsprojsStep : IMigrationStep
+{
+ ///
+ public Task ExecuteAsync(MigrationContext context, Summary summary)
+ {
+ foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.csproj"))
+ {
+ MigrationFileOperations.ApplyRewrite(
+ context,
+ path,
+ content => CsprojRewriter.Rewrite(content, context.FalloutVersion),
+ summary);
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Fallout.Migrate/ScriptRewriter.cs b/src/Fallout.Migrate/Steps/ScriptRewriter.cs
similarity index 62%
rename from src/Fallout.Migrate/ScriptRewriter.cs
rename to src/Fallout.Migrate/Steps/ScriptRewriter.cs
index 65e699ab8..ac3fbe25a 100644
--- a/src/Fallout.Migrate/ScriptRewriter.cs
+++ b/src/Fallout.Migrate/Steps/ScriptRewriter.cs
@@ -1,9 +1,16 @@
using System.Text.RegularExpressions;
+using Fallout.Migrate.Common;
-namespace Fallout.Migrate;
+namespace Fallout.Migrate.Steps;
+///
+/// Rewrites bootstrap scripts (build.cmd/build.ps1/build.sh): dotnet nuke
+/// invocations, .nuke path references, and legacy NUKE_* environment variables become
+/// their Fallout equivalents. Driven by .
+///
internal static class ScriptRewriter
{
+ /// The ordered find/replace patterns applied by .
private static readonly (Regex Pattern, string Replacement)[] patterns =
{
// `dotnet nuke` invocations
@@ -17,6 +24,12 @@ private static readonly (Regex Pattern, string Replacement)[] patterns =
(new Regex(@"\bNUKE_INTERNAL_INTERCEPTOR\b", RegexOptions.Compiled), "FALLOUT_INTERNAL_INTERCEPTOR"),
};
+ ///
+ /// Rewrites script content, applying every pattern in
+ /// in order.
+ ///
+ /// The original script file content.
+ /// The rewritten content and the number of edits made.
public static RewriteResult Rewrite(string original)
{
var edits = 0;
diff --git a/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs
index 1ecf70386..37bab7dcc 100644
--- a/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs
@@ -1,5 +1,6 @@
using FluentAssertions;
using Xunit;
+using Fallout.Migrate.Steps;
namespace Fallout.Migrate.Specs;
diff --git a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs
index 6579872dd..5f5f07731 100644
--- a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs
@@ -1,5 +1,6 @@
using FluentAssertions;
using Xunit;
+using Fallout.Migrate.Steps;
namespace Fallout.Migrate.Specs;
diff --git a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs
index 67f71f06d..4430a5fb1 100644
--- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Text;
+using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
@@ -9,14 +10,14 @@ namespace Fallout.Migrate.Specs;
public class MigrationIntegrationSpecs
{
[Fact]
- public void MigratesVanillaConsumerRepo()
+ public async Task MigratesVanillaConsumerRepo()
{
var temp = CreateVanillaFixture();
try
{
var migration = new Migration(temp, dryRun: false, TextWriter.Null);
- var summary = migration.Run();
+ var summary = await migration.RunAsync();
// Build file rewritten end to end.
var buildCsproj = File.ReadAllText(Path.Combine(temp, "build", "_build.csproj"));
@@ -53,7 +54,7 @@ public void MigratesVanillaConsumerRepo()
}
[Fact]
- public void DryRunDoesNotWriteFiles()
+ public async Task DryRunDoesNotWriteFiles()
{
var temp = CreateVanillaFixture();
@@ -62,7 +63,7 @@ public void DryRunDoesNotWriteFiles()
var beforeCsproj = File.ReadAllText(Path.Combine(temp, "build", "_build.csproj"));
var beforeNukeDir = Directory.Exists(Path.Combine(temp, ".nuke"));
- var summary = new Migration(temp, dryRun: true, TextWriter.Null).Run();
+ var summary = await new Migration(temp, dryRun: true, TextWriter.Null).RunAsync();
File.ReadAllText(Path.Combine(temp, "build", "_build.csproj")).Should().Be(beforeCsproj);
Directory.Exists(Path.Combine(temp, ".nuke")).Should().Be(beforeNukeDir);
@@ -75,14 +76,14 @@ public void DryRunDoesNotWriteFiles()
}
[Fact]
- public void WarnsWhenBothNukeAndFalloutDirectoriesExist()
+ public async Task WarnsWhenBothNukeAndFalloutDirectoriesExist()
{
var temp = CreateVanillaFixture();
Directory.CreateDirectory(Path.Combine(temp, ".fallout"));
try
{
- var summary = new Migration(temp, dryRun: false, TextWriter.Null).Run();
+ var summary = await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync();
summary.Warnings.Should().Contain(w => w.Contains(".nuke/") && w.Contains(".fallout/"));
summary.DirectoriesRenamed.Should().Be(0);
diff --git a/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs
index 9ead9dc3f..f9f4956a2 100644
--- a/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs
+++ b/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs
@@ -1,5 +1,6 @@
using FluentAssertions;
using Xunit;
+using Fallout.Migrate.Steps;
namespace Fallout.Migrate.Specs;