diff --git a/src/Fallout.Migrate/Steps/CleanupBootstrapScriptsStep.cs b/src/Fallout.Migrate/Steps/CleanupBootstrapScriptsStep.cs index a3fc79bdc..3b8faaa6b 100644 --- a/src/Fallout.Migrate/Steps/CleanupBootstrapScriptsStep.cs +++ b/src/Fallout.Migrate/Steps/CleanupBootstrapScriptsStep.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using Fallout.Common.IO; using Fallout.Migrate.Common; namespace Fallout.Migrate.Steps; @@ -15,14 +14,14 @@ internal partial class CleanupBootstrapScriptsStep : IMigrationStep { public Task ExecuteAsync(MigrationContext context, Summary summary) { - foreach (var file in new[] + // The scripts can sit in a subdirectory (NukeScriptDirectory), so search the whole tree. + foreach (var name in new[] { "build.sh", "build.ps1" }) { - var path = context.RootDirectory / file; - if (path.FileExists()) + foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, name)) { MigrationFileOperations.ApplyRewrite(context, path, Cleanup, summary); } @@ -50,11 +49,27 @@ private static RewriteResult Cleanup(string content) // here, we get the index of the line that contains the environment variable check var indexOfEnterpriseEnvVarCheck = lines.FindIndex(line => line.Contains(envVarToCheck)); + // The generated block starts with the `if` line that checks the token. Any other first use, + // for example `export NUKE_ENTERPRISE_TOKEN=...`, is not the generated block, so leave the + // file alone. Otherwise an unrelated `fi`/`}` later in the file would end the removal. + var checkLine = lines[indexOfEnterpriseEnvVarCheck].TrimStart(); + if (!checkLine.StartsWith("if ", StringComparison.OrdinalIgnoreCase) && + !checkLine.StartsWith("if(", StringComparison.OrdinalIgnoreCase)) + { + return new RewriteResult(content, 0); + } + // here, we get the index of the line that ends the if block // which is fi on bash and a simple "}" in powershell var endOfIfBlock = lines.FindIndex(indexOfEnterpriseEnvVarCheck, line => line.Trim() == "}" || line.Trim() == "fi"); + // No closing `fi`/`}` after the token: not the generated block, so leave the file alone. + if (endOfIfBlock < 0) + { + return new RewriteResult(content, 0); + } + // this is "just" to remove the empty line after the if block if (lines.Count > endOfIfBlock + 1 && lines[endOfIfBlock + 1].Trim() == "") { diff --git a/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs b/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs index a473895ea..2f8e43c2b 100644 --- a/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs +++ b/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Fallout.Common.IO; using Fallout.Migrate.Common; @@ -7,16 +8,35 @@ 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. +/// both already exist and need a manual merge. The build.schema.json inside is rewritten +/// first, so the moved file names its base definition FalloutBuild. /// internal sealed class RenameNukeDirectoryStep : IMigrationStep { + // NUKE's schema has the layout Fallout reads; only the base definition is named + // NukeBuild. CompletionUtility (fallout completion and :secrets) looks up FalloutBuild, + // so rename the definition key and its $ref. The next build rewrites the whole file. + // The lookbehind matches any quoted "NukeBuild", not only these two. That is safe because + // the schema is machine-generated, and NukeBuild appears only as the key and in the $ref. + private static readonly Regex nukeBuildDefinition = + new(@"(?<=""|#/definitions/)NukeBuild(?="")", RegexOptions.Compiled); + /// public Task ExecuteAsync(MigrationContext context, Summary summary) { var legacy = context.RootDirectory / ".nuke"; var canonical = context.RootDirectory / ".fallout"; + // Rewrite the schema in the directory that ends up as .fallout/: .nuke/ when it is moved, + // otherwise .fallout/. When both exist, .nuke/ is left for the manual merge. This also + // repairs a repository that an earlier fallout-migrate moved without rewriting. + var moveLegacy = legacy.DirectoryExists() && !canonical.DirectoryExists(); + var schema = (moveLegacy ? legacy : canonical) / "build.schema.json"; + if (schema.FileExists()) + { + MigrationFileOperations.ApplyRewrite(context, schema, RewriteSchema, summary); + } + if (!legacy.DirectoryExists()) { return Task.CompletedTask; @@ -43,4 +63,21 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) summary.DirectoriesRenamed++; return Task.CompletedTask; } + + /// + /// Renames the NukeBuild definition and its $ref in . + /// + /// The original build.schema.json content. + /// The rewritten content and the number of edits made. + private static RewriteResult RewriteSchema(string original) + { + var edits = 0; + var content = nukeBuildDefinition.Replace(original, _ => + { + edits++; + return "FalloutBuild"; + }); + + return new RewriteResult(content, edits); + } } diff --git a/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs b/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs index 741b118bc..c0bfc01bd 100644 --- a/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs @@ -1,6 +1,5 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -using Fallout.Common.IO; using Fallout.Migrate.Common; namespace Fallout.Migrate.Steps; @@ -33,6 +32,8 @@ private static readonly (Regex Pattern, string Replacement)[] patterns = /// public Task ExecuteAsync(MigrationContext context, Summary summary) { + // The scripts can sit in a subdirectory (NukeScriptDirectory), so search the whole tree. + // The patterns only match NUKE-specific text, and a script without any is not written. foreach (var name in new[] { "build.cmd", @@ -40,8 +41,7 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) "build.sh" }) { - var path = context.RootDirectory / name; - if (path.FileExists()) + foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, name)) { MigrationFileOperations.ApplyRewrite(context, path, Rewrite, summary); } diff --git a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs index 2b6064b12..dd6ad9315 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs @@ -7,7 +7,8 @@ namespace Fallout.Migrate.Steps; /// /// Rewrites every *.cs file under the repository root: Nuke.* namespace prefixes become /// Fallout., the bare NukeBuild/INukeBuild types become -/// FalloutBuild/IFalloutBuild, and the solution-model namespace (which moved out of +/// FalloutBuild/IFalloutBuild, the IHaz* component interfaces become +/// IHas*, and the solution-model namespace (which moved out of /// *.Common.ProjectModel in v11) becomes Fallout.Solutions. /// internal sealed class RewriteCsFilesStep : IMigrationStep @@ -32,6 +33,14 @@ internal sealed class RewriteCsFilesStep : IMigrationStep private static readonly Regex nukeBuildType = new(@"\bNukeBuild\b", RegexOptions.Compiled); private static readonly Regex iNukeBuildType = new(@"\bINukeBuild\b", RegexOptions.Compiled); + // NUKE's IHaz* component interfaces are IHas* in Fallout.Components. The [Obsolete] + // IHaz* aliases ship only in the Nuke.Components shim, which the namespace swap above + // moves the code away from. Limited to the shipped names, so a user-defined IHaz* + // interface keeps its name. + private static readonly Regex iHazComponentType = new( + @"\bIHaz(?=(?:Artifacts|Changelog|Configuration|GitRepository|GitVersion|NerdbankGitVersioning|Reports|Solution|TwitterCredentials)\b)", + RegexOptions.Compiled); + /// public Task ExecuteAsync(MigrationContext context, Summary summary) { @@ -77,6 +86,12 @@ private static RewriteResult Rewrite(string original) return "FalloutBuild"; }); + content = iHazComponentType.Replace(content, _ => + { + edits++; + return "IHas"; + }); + return new RewriteResult(content, edits); } } diff --git a/tests/Fallout.Migrate.Specs/RenameNukeDirectoryStepSpecs.cs b/tests/Fallout.Migrate.Specs/RenameNukeDirectoryStepSpecs.cs new file mode 100644 index 000000000..21278365d --- /dev/null +++ b/tests/Fallout.Migrate.Specs/RenameNukeDirectoryStepSpecs.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Fallout.Common.IO; +using Fallout.Migrate.Common; +using Fallout.Migrate.Steps; +using FluentAssertions; +using Xunit; + +namespace Fallout.Migrate.Specs; + +public class RenameNukeDirectoryStepSpecs : IDisposable +{ + private const string NukeSchema = """ + { + "definitions": { + "Host": { "type": "string" }, + "NukeBuild": { "properties": { "Verbosity": { "type": "string" } } } + }, + "allOf": [ + { "properties": { "Configuration": { "type": "string" } } }, + { "$ref": "#/definitions/NukeBuild" } + ] + } + """; + + private readonly AbsolutePath tempDirectory; + private readonly Summary summary = new(); + + public RenameNukeDirectoryStepSpecs() + { + tempDirectory = AbsolutePath.Temp("fallout-migrate-test"); + } + + public void Dispose() + { + tempDirectory.DeleteDirectory(); + } + + [Fact] + public async Task Moved_schema_names_its_base_definition_FalloutBuild() + { + // Arrange + (tempDirectory / ".nuke" / "build.schema.json").WriteAllText(NukeSchema, eofLineBreak: false); + var context = new MigrationContext(tempDirectory, dryRun: false, TextWriter.Null); + + // Act + await new RenameNukeDirectoryStep().ExecuteAsync(context, summary); + + // Assert + summary.DirectoriesRenamed.Should().Be(1); + summary.EditCount.Should().Be(2); + var schema = (tempDirectory / ".fallout" / "build.schema.json").ReadAllText(); + schema.Should().Contain("\"FalloutBuild\": {"); + schema.Should().Contain("\"$ref\": \"#/definitions/FalloutBuild\""); + schema.Should().NotContain("NukeBuild"); + } + + [Fact] + public async Task Schema_of_an_already_migrated_repo_is_repaired() + { + // Arrange + (tempDirectory / ".fallout" / "build.schema.json").WriteAllText(NukeSchema, eofLineBreak: false); + var context = new MigrationContext(tempDirectory, dryRun: false, TextWriter.Null); + + // Act + await new RenameNukeDirectoryStep().ExecuteAsync(context, summary); + + // Assert + summary.DirectoriesRenamed.Should().Be(0); + summary.EditCount.Should().Be(2); + (tempDirectory / ".fallout" / "build.schema.json").ReadAllText().Should().NotContain("NukeBuild"); + } + + [Fact] + public async Task Only_the_fallout_schema_is_rewritten_when_both_directories_exist() + { + // Arrange + (tempDirectory / ".nuke" / "build.schema.json").WriteAllText(NukeSchema, eofLineBreak: false); + (tempDirectory / ".fallout" / "build.schema.json").WriteAllText(NukeSchema, eofLineBreak: false); + var context = new MigrationContext(tempDirectory, dryRun: false, TextWriter.Null); + + // Act + await new RenameNukeDirectoryStep().ExecuteAsync(context, summary); + + // Assert + summary.DirectoriesRenamed.Should().Be(0); + summary.EditCount.Should().Be(2); + summary.Warnings.Should().ContainSingle().Which.Should().Contain("merge their contents manually"); + (tempDirectory / ".fallout" / "build.schema.json").ReadAllText().Should().NotContain("NukeBuild"); + (tempDirectory / ".nuke" / "build.schema.json").ReadAllText().Should().Be(NukeSchema); + } + + [Fact] + public async Task Dry_run_leaves_the_schema_unchanged() + { + // Arrange + (tempDirectory / ".nuke" / "build.schema.json").WriteAllText(NukeSchema, eofLineBreak: false); + var context = new MigrationContext(tempDirectory, dryRun: true, TextWriter.Null); + + // Act + await new RenameNukeDirectoryStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(2); + (tempDirectory / ".nuke" / "build.schema.json").ReadAllText().Should().Be(NukeSchema); + (tempDirectory / ".fallout").DirectoryExists().Should().BeFalse(); + } +} diff --git a/tests/Fallout.Migrate.Specs/RewriteBootstrapScriptsStepSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteBootstrapScriptsStepSpecs.cs index b437f0995..57247ed40 100644 --- a/tests/Fallout.Migrate.Specs/RewriteBootstrapScriptsStepSpecs.cs +++ b/tests/Fallout.Migrate.Specs/RewriteBootstrapScriptsStepSpecs.cs @@ -196,4 +196,90 @@ public async Task Leaves_bootstrapper_scripts_without_leftovers_alone() "$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" """); } + + [Fact] + public async Task Bootstrap_scripts_in_a_subdirectory_are_rewritten() + { + // Arrange + (tempDirectory / "eng" / "build.sh").WriteAllText(""" + TEMP_DIRECTORY="$SCRIPT_DIR/../.nuke/temp" + dotnet nuke "$@" + """, eofLineBreak: false); + + // Act + await new RewriteBootstrapScriptsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(2); + var buildSh = (tempDirectory / "eng" / "build.sh").ReadAllText(); + buildSh.Should().Contain("/.fallout/temp"); + buildSh.Should().Contain("dotnet fallout"); + } + + [Fact] + public async Task Unrelated_build_scripts_are_left_untouched() + { + // Arrange + var original = "#!/bin/sh\nmake all\n"; + (tempDirectory / "tools" / "build.sh").WriteAllText(original, eofLineBreak: false); + + // Act + await new RewriteBootstrapScriptsStep().ExecuteAsync(context, summary); + await new CleanupBootstrapScriptsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(0); + (tempDirectory / "tools" / "build.sh").ReadAllText().Should().Be(original); + } + + [Fact] + public async Task Enterprise_feed_block_in_a_subdirectory_script_is_removed() + { + // Arrange + (tempDirectory / "eng" / "build.ps1").WriteAllText(""" + if (Test-Path env:NUKE_ENTERPRISE_TOKEN) { + & $env:DOTNET_EXE nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password $env:NUKE_ENTERPRISE_TOKEN > $null + } + + ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } + """, eofLineBreak: false); + + // Act + await new CleanupBootstrapScriptsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(1); + (tempDirectory / "eng" / "build.ps1").ReadAllText().Should().Be( + "ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments }"); + } + + [Fact] + public async Task Enterprise_token_without_a_closing_block_is_left_alone() + { + // Arrange + var original = "export NUKE_ENTERPRISE_TOKEN=\"$1\"\ndotnet nuke \"$@\""; + (tempDirectory / "build.sh").WriteAllText(original, eofLineBreak: false); + + // Act + await new CleanupBootstrapScriptsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(0); + (tempDirectory / "build.sh").ReadAllText().Should().Be(original); + } + + [Fact] + public async Task Enterprise_token_outside_an_if_line_is_left_alone() + { + // Arrange + var original = "export NUKE_ENTERPRISE_TOKEN=\"$1\"\necho \"start\"\nif [ -f x ]; then echo y; fi\ndotnet nuke \"$@\""; + (tempDirectory / "build.sh").WriteAllText(original, eofLineBreak: false); + + // Act + await new CleanupBootstrapScriptsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(0); + (tempDirectory / "build.sh").ReadAllText().Should().Be(original); + } } diff --git a/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs index 8cc688983..86e56c0f5 100644 --- a/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs +++ b/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Fallout.Common.IO; using Fallout.Migrate.Common; @@ -157,4 +159,62 @@ public async Task ProjectModel_as_part_of_another_identifier_is_not_truncated() var buildCs = (tempDirectory / "Build.cs").ReadAllText().Trim(); buildCs.Should().Be("using Fallout.Common.ProjectModelFoo;"); } + + [Fact] + public async Task IHaz_component_interfaces_are_renamed_to_IHas() + { + // Arrange + (tempDirectory / "Build.cs").WriteAllText(""" + interface IBuildPaths : IHazSolution, IHazArtifacts; + var solution = From().Solution; + """); + + // Act + await new RewriteCsFilesStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(3); + var buildCs = (tempDirectory / "Build.cs").ReadAllText(); + buildCs.Should().Contain("IBuildPaths : IHasSolution, IHasArtifacts;"); + buildCs.Should().Contain("From()"); + buildCs.Should().NotContain("IHaz"); + } + + [Fact] + public async Task User_defined_IHaz_interfaces_keep_their_name() + { + // Arrange + (tempDirectory / "Build.cs").WriteAllText("interface IHazSourcePaths : IHazSolution;"); + + // Act + await new RewriteCsFilesStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(1); + var buildCs = (tempDirectory / "Build.cs").ReadAllText().Trim(); + buildCs.Should().Be("interface IHazSourcePaths : IHasSolution;"); + } + + [Fact] + public async Task Every_IHaz_alias_in_the_Nuke_Components_shim_is_renamed() + { + // The rename list is hard-coded. This spec fails when the shim gains an IHaz* alias + // that the list doesn't have. + // Arrange + var shimDirectory = AbsolutePath.Create(AppContext.BaseDirectory) + .FindParentOrSelf(x => (x / "fallout.slnx").FileExists()) / "src" / "Shims" / "Nuke.Components"; + var aliases = shimDirectory.GetFiles("*.cs") + .SelectMany(x => Regex.Matches(x.ReadAllText(), @"\binterface\s+(?IHaz\w+)")) + .Select(x => x.Groups["name"].Value) + .ToList(); + (tempDirectory / "Build.cs").WriteAllText(string.Join("\n", aliases), eofLineBreak: false); + + // Act + await new RewriteCsFilesStep().ExecuteAsync(context, summary); + + // Assert + aliases.Should().NotBeEmpty(); + (tempDirectory / "Build.cs").ReadAllText() + .Should().Be(string.Join("\n", aliases.Select(x => "IHas" + x["IHaz".Length..]))); + } }