diff --git a/src/Fallout.Migrate/Common/MigrationFileOperations.cs b/src/Fallout.Migrate/Common/MigrationFileOperations.cs index a5ba5e44b..ba00eb7be 100644 --- a/src/Fallout.Migrate/Common/MigrationFileOperations.cs +++ b/src/Fallout.Migrate/Common/MigrationFileOperations.cs @@ -22,7 +22,7 @@ public static IEnumerable EnumerateFiles(AbsolutePath rootDirector { foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue)) { - if (IsIgnored(file)) + if (IsIgnored(rootDirectory, file)) { continue; } @@ -88,13 +88,21 @@ public static string RelativePath(AbsolutePath rootDirectory, AbsolutePath absol /// /// Returns true if sits under a bin/, obj/, or - /// .git/ directory and should be skipped by . + /// .git/ directory below and should be skipped by + /// . Only the path below the root counts, so a repository cloned + /// under a folder named bin or obj is still migrated. /// - private static bool IsIgnored(AbsolutePath path) + private static bool IsIgnored(AbsolutePath rootDirectory, 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); + 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; } } diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs index 9fcd30674..993c8bf2f 100644 --- a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -50,6 +50,30 @@ internal sealed class BumpDotNetVersionStep : IMigrationStep @"(?<=""sdk""\s*:\s*\{[^}]*?""version""\s*:\s*"")[^""]+", RegexOptions.Compiled | RegexOptions.Singleline); + /// + /// The roll-forward policy added to global.json when the SDK is pinned and no policy is set. + /// Without one, the default patch 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. + /// Fallout's own global.json uses latestMinor. latestFeature is used here + /// because it allows a later feature band but never a later minor version, so it changes less + /// in the migrated repository. + /// + private const string RollForward = "latestFeature"; + + /// + /// 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. + /// + private static readonly string[] pinnedFeatureBandPolicies = ["patch", "latestPatch", "disable"]; + + /// + /// Matches a rollForward property inside global.json's sdk object, and + /// captures its value. + /// + private static readonly Regex sdkRollForwardPattern = new( + @"""sdk""\s*:\s*\{[^}]*""rollForward""(?:\s*:\s*""(?[^""]*)"")?", + RegexOptions.Compiled | RegexOptions.Singleline); + /// public Task ExecuteAsync(MigrationContext context, Summary summary) { @@ -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; } + /// + /// Returns the rollForward value of when it is one of + /// , otherwise null. + /// + /// The global.json content after the SDK pin was bumped. + 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; + } + /// /// Rewrites a build project's TargetFramework element to /// when its current moniker is behind . @@ -91,7 +145,8 @@ private static RewriteResult BumpTargetFramework(string original) /// /// Rewrites global.json's sdk.version to only when the - /// current version is behind . + /// current version is behind , and adds + /// when the sdk object has no rollForward yet. /// /// The original global.json content. /// The rewritten content and the number of edits made. @@ -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)) + { + 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); + } + + /// + /// Returns the text to put before a new property next to the version property: a line + /// break plus the same indentation when version starts its own line, otherwise a space. + /// + /// The original global.json content. + /// The index of the sdk.version value. + 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]; } /// diff --git a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs index 2b6064b12..77fb55b75 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs @@ -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 `(?