diff --git a/src/GitVersion.App.Tests/ArgumentParserTests.cs b/src/GitVersion.App.Tests/ArgumentParserTests.cs index 66185de591..a0f71b0c58 100644 --- a/src/GitVersion.App.Tests/ArgumentParserTests.cs +++ b/src/GitVersion.App.Tests/ArgumentParserTests.cs @@ -142,6 +142,53 @@ public void OverrideConfigBatchValidationDoesNotApplyAnyValues() parser.GetOverrideConfiguration().ShouldBeEmpty(); } + [Test] + public void ConfigMigrateParsesItsInputAndOutputOptions() + { + var arguments = this.argumentParser.ParseArguments( + "config migrate --config GitVersion.yml --output GitVersion.v7.yml --force"); + + arguments.IsConfigurationMigration.ShouldBeTrue(); + arguments.MigrationInputFile.ShouldBe("GitVersion.yml"); + arguments.MigrationOutputFile.ShouldBe("GitVersion.v7.yml"); + arguments.MigrationForce.ShouldBeTrue(); + } + + [Test] + public void ConfigMigrateUsesPositionalTargetPath() + { + var arguments = this.argumentParser.ParseArguments("path config migrate --in-place"); + + arguments.IsConfigurationMigration.ShouldBeTrue(); + arguments.TargetPath.ShouldBe("path"); + } + + [Test] + public void ConfigMigrateRejectsOutputAndInPlaceTogether() + { + var exception = Should.Throw(() => + this.argumentParser.ParseArguments("config migrate --output GitVersion.v7.yml --in-place")); + + exception.Message.ShouldBe("Cannot use --output together with --in-place."); + } + + [Test] + public void ConfigRequiresASubcommand() + { + var exception = Should.Throw(() => this.argumentParser.ParseArguments("config")); + + exception.Message.ShouldBe("The 'config' command requires a subcommand. Use 'gitversion config migrate'."); + } + + [Test] + public void TargetPathAllowsDirectoryNamedConfig() + { + var arguments = this.argumentParser.ParseArguments("--target-path config"); + + arguments.TargetPath.ShouldBe("config"); + arguments.IsConfigurationMigration.ShouldBeFalse(); + } + [Test] public void EmptyMeansUseCurrentDirectory() { diff --git a/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs b/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs new file mode 100644 index 0000000000..28bbc5e53b --- /dev/null +++ b/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs @@ -0,0 +1,39 @@ +using System.IO.Abstractions; +using GitVersion.Configuration; +using GitVersion.Tests; + +namespace GitVersion.App.Tests; + +[TestFixture] +public class ConfigurationMigrationExecutorTests +{ + [Test] + public void InPlaceMigrationWarnsThatCommentsCannotBePreserved() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var inputFile = Path.Combine(directory.FullName, "legacy.yml"); + File.WriteAllText(inputFile, "next-version: 2.0.0"); + var logMessages = new List(); + var executor = new ConfigurationMigrationExecutor( + new FileSystem(), + new TestConsoleAdapter(new StringBuilder()), + new TestLogger(logMessages.Add), + Substitute.For(), + new ConfigurationMigrationService(new ConfigurationSerializer())); + var options = new GitVersionOptions { WorkingDirectory = directory.FullName }; + options.ConfigurationMigrationInfo.IsMigration = true; + options.ConfigurationMigrationInfo.InputFile = inputFile; + options.ConfigurationMigrationInfo.InPlace = true; + + executor.Execute(options).ShouldBe(0); + + logMessages.ShouldContain(message => message.Contains("Comments cannot be preserved during migration.", StringComparison.Ordinal)); + } + finally + { + directory.Delete(recursive: true); + } + } +} diff --git a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs index 5d4e486f9e..f75f4fb7bc 100644 --- a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs +++ b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs @@ -5,8 +5,163 @@ namespace GitVersion.App.Tests; [TestFixture] +[NonParallelizable] public class ConfigurationVersionIntegrationTests { + [Test] + public async Task ConfigMigrateWritesMigratedConfigurationToStandardOutput() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var configurationPath = Path.Combine(directory.FullName, ConfigurationFileLocator.DefaultFileName); + await File.WriteAllTextAsync(configurationPath, "next-version: 2.0.0"); + + var result = await new ProgramFixture(directory.FullName).Run("config", "migrate"); + + result.ExitCode.ShouldBe(0); + result.Output.ShouldNotBeNull(); + result.Output.ShouldContain("calculation:"); + result.Output.ShouldContain("next-version: 2.0.0"); + result.Output.ShouldContain("output: {}"); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + public async Task ConfigMigrateDoesNotOverwriteOutputWithoutForce() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var configurationPath = Path.Combine(directory.FullName, ConfigurationFileLocator.DefaultFileName); + var outputPath = Path.Combine(directory.FullName, "GitVersion.v7.yml"); + await File.WriteAllTextAsync(configurationPath, "next-version: 2.0.0"); + await File.WriteAllTextAsync(outputPath, "existing configuration"); + + var result = await new ProgramFixture(directory.FullName).Run("config", "migrate", "--output", "GitVersion.v7.yml"); + + result.ExitCode.ShouldBe(1); + result.Output.ShouldBeEmpty(); + var output = await File.ReadAllTextAsync(outputPath); + output.ShouldBe("existing configuration"); + + var forceResult = GitVersionHelper.ExecuteIn( + directory.FullName, + " config migrate --output GitVersion.v7.yml --force", + logToFile: false); + + forceResult.ExitCode.ShouldBe(0); + forceResult.Output.ShouldNotBeNull(); + forceResult.Output.ShouldContain("Comments cannot be preserved during migration."); + var forcedOutput = await File.ReadAllTextAsync(outputPath); + forcedOutput.ShouldContain("calculation:"); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + public async Task ConfigMigrateLeavesNoTemporaryFileAfterWritingOutput() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var configurationPath = Path.Combine(directory.FullName, ConfigurationFileLocator.DefaultFileName); + await File.WriteAllTextAsync(configurationPath, "next-version: 2.0.0"); + + var result = await new ProgramFixture(directory.FullName).Run("config", "migrate", "--output", "GitVersion.v7.yml"); + + result.ExitCode.ShouldBe(0); + var files = Directory.GetFiles(directory.FullName).Select(Path.GetFileName).ToArray(); + files.Length.ShouldBe(2); + files.ShouldContain(ConfigurationFileLocator.DefaultFileName); + files.ShouldContain("GitVersion.v7.yml"); + files.ShouldNotContain(file => file!.StartsWith(".", StringComparison.Ordinal)); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + public async Task ConfigMigrateInPlaceMigratesExplicitConfigurationOutsideGitRepository() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + const string fileName = "legacy.yml"; + var configurationPath = Path.Combine(directory.FullName, fileName); + await File.WriteAllTextAsync(configurationPath, "next-version: 2.0.0"); + + var result = GitVersionHelper.ExecuteIn( + directory.FullName, + $" config migrate --config {fileName} --in-place", + logToFile: false); + + result.ExitCode.ShouldBe(0); + result.Output.ShouldNotBeNull(); + result.Output.ShouldContain("Comments cannot be preserved during migration."); + var migratedConfiguration = await File.ReadAllTextAsync(configurationPath); + migratedConfiguration.ShouldContain("calculation:"); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + public async Task ConfigMigrateInPlaceUsesPositionalTargetPath() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var configurationPath = Path.Combine(directory.FullName, ConfigurationFileLocator.DefaultFileName); + await File.WriteAllTextAsync(configurationPath, "next-version: 2.0.0"); + + var result = await new ProgramFixture().Run(directory.FullName, "config", "migrate", "--in-place"); + + result.ExitCode.ShouldBe(0); + var migratedConfiguration = await File.ReadAllTextAsync(configurationPath); + migratedConfiguration.ShouldContain("calculation:"); + migratedConfiguration.ShouldContain("next-version: 2.0.0"); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Test] + public async Task ConfigMigrateDoesNotEmitLegacyFallbackWarning() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var configurationPath = Path.Combine(directory.FullName, ConfigurationFileLocator.DefaultFileName); + await File.WriteAllTextAsync(configurationPath, "next-version: 2.0.0"); + var fixture = new ProgramFixture(directory.FullName); + fixture.WithEnv(new KeyValuePair(ConfigurationVersionSelector.EnvironmentVariableName, "v6")); + + var result = await fixture.Run("config", "migrate"); + + result.ExitCode.ShouldBe(0); + result.Output!.ShouldNotContain("temporary v6 compatibility mode"); + result.Log!.ShouldNotContain("temporary v6 compatibility mode"); + } + finally + { + directory.Delete(recursive: true); + } + } + [Test] public void V6AndV7ConfigurationCalculateTheSameVersion() { diff --git a/src/GitVersion.App.Tests/HelpWriterTests.cs b/src/GitVersion.App.Tests/HelpWriterTests.cs index 9f070913c8..d0e8716a1e 100644 --- a/src/GitVersion.App.Tests/HelpWriterTests.cs +++ b/src/GitVersion.App.Tests/HelpWriterTests.cs @@ -55,7 +55,12 @@ public void AllArgsAreInHelp() var ignored = new[] { nameof(Arguments.Authentication), - nameof(Arguments.UpdateAssemblyInfoFileName) + nameof(Arguments.UpdateAssemblyInfoFileName), + nameof(Arguments.IsConfigurationMigration), + nameof(Arguments.MigrationInputFile), + nameof(Arguments.MigrationOutputFile), + nameof(Arguments.MigrationInPlace), + nameof(Arguments.MigrationForce) }; typeof(Arguments).GetFields() .Select(p => p.Name) diff --git a/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs b/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs index 42fc182b28..4cfbeae665 100644 --- a/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs +++ b/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs @@ -63,6 +63,14 @@ public void OverrideConfigRejectsV6PathInV7WithReplacement() exception.Message.ShouldContain("config migrate"); } + [Test] + public void ConfigMigrateIsRejectedAsASubcommand() + { + var exception = Should.Throw(() => this.argumentParser.ParseArguments("config migrate")); + + exception.Message.ShouldContain("only available with the POSIX argument parser"); + } + [Test] public void EmptyMeansUseCurrentDirectory() { diff --git a/src/GitVersion.App/ArgumentParser.cs b/src/GitVersion.App/ArgumentParser.cs index f271ddb9bd..f78c2394c3 100644 --- a/src/GitVersion.App/ArgumentParser.cs +++ b/src/GitVersion.App/ArgumentParser.cs @@ -62,6 +62,16 @@ public Arguments ParseArguments(string[] commandLineArguments) return new Arguments { IsVersion = true }; } + if (parseResult.CommandResult.Command == options.ConfigCommand) + { + throw new WarningException("The 'config' command requires a subcommand. Use 'gitversion config migrate'."); + } + + if (parseResult.CommandResult.Command == options.Migrate) + { + return MapMigrationValues(parseResult, options); + } + var arguments = new Arguments(); AddAuthentication(arguments); MapParsedValues(arguments, parseResult, options); @@ -78,6 +88,12 @@ bool IsOptionExplicitlySet() where T : Option private static void ValidateParsedResult(ParseResult parseResult, CommandOptions options) { + if (parseResult.CommandResult.Command == options.ConfigCommand + && parseResult.Errors.Any(error => error.Message.Contains("Required command was not provided", StringComparison.Ordinal))) + { + throw new WarningException("The 'config' command requires a subcommand. Use 'gitversion config migrate'."); + } + if (parseResult.Errors.Count > 0) { var message = parseResult.Errors[0].Message; @@ -135,6 +151,34 @@ private void MapParsedValues(Arguments arguments, ParseResult parseResult, Comma ApplyDefaults(arguments, parseResult, options); } + private static Arguments MapMigrationValues(ParseResult parseResult, CommandOptions options) + { + var outputFile = parseResult.GetValue(options.MigrationOutputFile); + var inPlace = parseResult.GetValue(options.InPlace); + var force = parseResult.GetValue(options.Force); + if (outputFile is not null && inPlace) + { + throw new WarningException("Cannot use --output together with --in-place."); + } + + if (force && outputFile is null) + { + throw new WarningException("--force can only be used together with --output."); + } + + return new Arguments + { + IsConfigurationMigration = true, + MigrationInputFile = parseResult.GetValue(options.MigrationInputFile), + MigrationOutputFile = outputFile, + MigrationInPlace = inPlace, + MigrationForce = force, + TargetPath = parseResult.GetValue(options.TargetPath) + ?? parseResult.GetValue(options.Path) + ?? SysEnv.CurrentDirectory + }; + } + private static void MapOutputOptions(Arguments arguments, ParseResult parseResult, CommandOptions options) { if (parseResult.GetValue(options.Output) is { } outputs) @@ -451,6 +495,17 @@ Allows GitVersion to run on a shallow clone. { Description = "By default dynamic repositories will be cloned to %tmp%. Use this option to override" }; + var configCommand = new Command("config", "Manages GitVersion configuration."); + var migrate = new Command("migrate", "Migrates a v6 configuration document to the v7 structure."); + var migrationInputFile = new Option("--config") { Description = "Path to the configuration file to migrate." }; + var migrationOutputFile = new Option("--output") { Description = "Path to write the migrated configuration document." }; + var inPlace = new Option("--in-place") { Description = "Replace the input configuration file." }; + var force = new Option("--force") { Description = "Allow --output to replace an existing file." }; + migrate.Options.Add(migrationInputFile); + migrate.Options.Add(migrationOutputFile); + migrate.Options.Add(inPlace); + migrate.Options.Add(force); + configCommand.Subcommands.Add(migrate); var rootCommand = new RootCommand("Use convention to derive a SemVer product version from a GitFlow or GitHub based repository.") { @@ -481,6 +536,11 @@ Allows GitVersion to run on a shallow clone. commit, dynamicRepoLocation }; + rootCommand.Subcommands.Add(configCommand); + + // System.CommandLine requires an action on the root command so normal version calculation + // remains a valid invocation while subcommands provide their own parsing paths. + rootCommand.SetAction(_ => { }); // Configure the built-in help system to wrap at 260 characters to avoid too small help messages var helpOption = rootCommand.Options.SingleOfType(); @@ -497,7 +557,10 @@ Allows GitVersion to run on a shallow clone. VerbosityOption: verbosity, UpdateAssemblyInfo: updateAssemblyInfo, UpdateProjectFiles: updateProjectFiles, EnsureAssemblyInfo: ensureAssemblyInfo, UpdateWixVersionFile: updateWixVersionFile, Url: url, Branch: branch, Username: username, Password: password, - Commit: commit, DynamicRepoLocation: dynamicRepoLocation + Commit: commit, DynamicRepoLocation: dynamicRepoLocation, + ConfigCommand: configCommand, Migrate: migrate, + MigrationInputFile: migrationInputFile, MigrationOutputFile: migrationOutputFile, + InPlace: inPlace, Force: force )); } @@ -621,6 +684,12 @@ private sealed record CommandOptions( Option Username, Option Password, Option Commit, - Option DynamicRepoLocation + Option DynamicRepoLocation, + Command ConfigCommand, + Command Migrate, + Option MigrationInputFile, + Option MigrationOutputFile, + Option InPlace, + Option Force ); } diff --git a/src/GitVersion.App/Arguments.cs b/src/GitVersion.App/Arguments.cs index c58e6a6557..c332c947de 100644 --- a/src/GitVersion.App/Arguments.cs +++ b/src/GitVersion.App/Arguments.cs @@ -9,6 +9,11 @@ internal class Arguments public string? ConfigurationFile; public IReadOnlyDictionary OverrideConfiguration = new Dictionary(); public bool ShowConfiguration; + public bool IsConfigurationMigration; + public string? MigrationInputFile; + public string? MigrationOutputFile; + public bool MigrationInPlace; + public bool MigrationForce; public string? TargetPath; @@ -64,6 +69,15 @@ public GitVersionOptions ToOptions() ShowConfiguration = this.ShowConfiguration }, + ConfigurationMigrationInfo = + { + IsMigration = this.IsConfigurationMigration, + InputFile = this.MigrationInputFile, + OutputFile = this.MigrationOutputFile, + InPlace = this.MigrationInPlace, + Force = this.MigrationForce + }, + RepositoryInfo = { TargetUrl = this.TargetUrl, diff --git a/src/GitVersion.App/ConfigurationMigrationExecutor.cs b/src/GitVersion.App/ConfigurationMigrationExecutor.cs new file mode 100644 index 0000000000..0dfc4df028 --- /dev/null +++ b/src/GitVersion.App/ConfigurationMigrationExecutor.cs @@ -0,0 +1,73 @@ +using System.IO.Abstractions; +using GitVersion.Configuration; +using GitVersion.Extensions; + +namespace GitVersion; + +internal class ConfigurationMigrationExecutor( + IFileSystem fileSystem, + IConsole console, + ILogger logger, + IConfigurationFileLocator configurationFileLocator, + IConfigurationMigrationService migrationService) : IConfigurationMigrationExecutor +{ + private readonly IFileSystem fileSystem = fileSystem.NotNull(); + private readonly IConsole console = console.NotNull(); + private readonly ILogger logger = logger.NotNull(); + private readonly IConfigurationFileLocator configurationFileLocator = configurationFileLocator.NotNull(); + private readonly IConfigurationMigrationService migrationService = migrationService.NotNull(); + + public int Execute(GitVersionOptions options) + { + var migration = options.ConfigurationMigrationInfo; + var inputFile = migration.InputFile is null + ? this.configurationFileLocator.GetConfigurationFile(options.WorkingDirectory) + : Path.GetFullPath(migration.InputFile, options.WorkingDirectory); + if (inputFile is null || !this.fileSystem.File.Exists(inputFile)) + { + throw new WarningException("Could not find a configuration file to migrate. Specify one with --config."); + } + + var migrated = this.migrationService.Migrate(this.fileSystem.File.ReadAllText(inputFile)); + if (!migration.InPlace && migration.OutputFile is null) + { + this.console.Write(migrated); + return 0; + } + + var outputFile = migration.InPlace ? inputFile : Path.GetFullPath(migration.OutputFile!, options.WorkingDirectory); + var overwritesExistingFile = this.fileSystem.File.Exists(outputFile); + if (!migration.InPlace && overwritesExistingFile && !migration.Force) + { + throw new WarningException($"The output file '{outputFile}' already exists. Use --force to replace it."); + } + + if (overwritesExistingFile) + { + Console.Error.WriteLine($"Replacing '{outputFile}'. Comments cannot be preserved during migration."); + this.logger.LogWarning("Replacing '{ConfigurationFile}'. Comments cannot be preserved during migration.", outputFile); + } + + WriteAtomically(outputFile, migrated); + return 0; + } + + private void WriteAtomically(string outputFile, string migrated) + { + var directory = this.fileSystem.Path.GetDirectoryName(outputFile)!; + var temporaryFile = this.fileSystem.Path.Combine(directory, $".{this.fileSystem.Path.GetFileName(outputFile)}.{Guid.NewGuid():N}.tmp"); + + try + { + this.fileSystem.File.WriteAllText(temporaryFile, migrated); + this.fileSystem.File.Move(temporaryFile, outputFile, overwrite: true); + } + finally + { + if (this.fileSystem.File.Exists(temporaryFile)) + { + this.fileSystem.File.Delete(temporaryFile); + } + } + } +} diff --git a/src/GitVersion.App/GitVersionApp.cs b/src/GitVersion.App/GitVersionApp.cs index e5450a136f..40e8ca0ef2 100644 --- a/src/GitVersion.App/GitVersionApp.cs +++ b/src/GitVersion.App/GitVersionApp.cs @@ -5,10 +5,12 @@ namespace GitVersion; internal class GitVersionApp( IHostApplicationLifetime applicationLifetime, IGitVersionExecutor gitVersionExecutor, + IConfigurationMigrationExecutor configurationMigrationExecutor, IOptions options) { private readonly IHostApplicationLifetime applicationLifetime = applicationLifetime.NotNull(); private readonly IGitVersionExecutor gitVersionExecutor = gitVersionExecutor.NotNull(); + private readonly IConfigurationMigrationExecutor configurationMigrationExecutor = configurationMigrationExecutor.NotNull(); private readonly IOptions options = options.NotNull(); public Task RunAsync(CancellationToken _) @@ -20,6 +22,10 @@ public Task RunAsync(CancellationToken _) { SysEnv.ExitCode = 0; } + else if (gitVersionOptions.ConfigurationMigrationInfo.IsMigration) + { + SysEnv.ExitCode = this.configurationMigrationExecutor.Execute(gitVersionOptions); + } else { SysEnv.ExitCode = this.gitVersionExecutor.Execute(gitVersionOptions); diff --git a/src/GitVersion.App/GitVersionAppModule.cs b/src/GitVersion.App/GitVersionAppModule.cs index 1bf2aa492c..6f74bedc5e 100644 --- a/src/GitVersion.App/GitVersionAppModule.cs +++ b/src/GitVersion.App/GitVersionAppModule.cs @@ -19,6 +19,7 @@ public void RegisterTypes(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => diff --git a/src/GitVersion.App/IConfigurationMigrationExecutor.cs b/src/GitVersion.App/IConfigurationMigrationExecutor.cs new file mode 100644 index 0000000000..88ee7793e0 --- /dev/null +++ b/src/GitVersion.App/IConfigurationMigrationExecutor.cs @@ -0,0 +1,6 @@ +namespace GitVersion; + +internal interface IConfigurationMigrationExecutor +{ + int Execute(GitVersionOptions options); +} diff --git a/src/GitVersion.App/LegacyArgumentParser.cs b/src/GitVersion.App/LegacyArgumentParser.cs index 24a8410bfc..70541d363f 100644 --- a/src/GitVersion.App/LegacyArgumentParser.cs +++ b/src/GitVersion.App/LegacyArgumentParser.cs @@ -78,6 +78,12 @@ public Arguments ParseArguments(string[] commandLineArguments) return new Arguments { IsVersion = true }; } + if (firstArgument.Equals("config", StringComparison.OrdinalIgnoreCase) + && commandLineArguments.Skip(1).FirstOrDefault()?.Equals("migrate", StringComparison.OrdinalIgnoreCase) == true) + { + throw new WarningException("The 'config migrate' command is only available with the POSIX argument parser."); + } + var arguments = new Arguments(); AddAuthentication(arguments); diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs new file mode 100644 index 0000000000..c04df1c725 --- /dev/null +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs @@ -0,0 +1,90 @@ +using SharpYaml; + +namespace GitVersion.Configuration.Tests; + +[TestFixture] +public class ConfigurationMigrationServiceTests +{ + private readonly IConfigurationMigrationService migrationService = new ConfigurationMigrationService(new ConfigurationSerializer()); + + [Test] + public void MigratesFlatConfigurationToCalculationAndOutputSections() + { + const string input = """ + workflow: GitFlow/v1 + tag-prefix: custom- + update-build-number: false + branches: + main: + increment: Major + pre-release-weight: 42 + """; + + var result = this.migrationService.Migrate(input); + + result.ShouldContain("calculation:"); + result.ShouldContain(" workflow: GitFlow/v1"); + result.ShouldContain(" tag-prefix: custom-"); + result.ShouldContain(" increment: Major"); + result.ShouldContain("output:"); + result.ShouldContain(" update-build-number: false"); + result.ShouldContain(" pre-release-weight: 42"); + } + + [Test] + public void AcceptsNestedConfigurationAndProducesDeterministicOutput() + { + const string input = """ + output: + update-build-number: false + calculation: + tag-prefix: custom- + """; + + var result = this.migrationService.Migrate(input); + + this.migrationService.Migrate(result).ShouldBe(result); + } + + [Test] + public void MigratesConfiguredValuesWithoutAddingDefaults() + { + const string input = """ + workflow: GitHubFlow/v1 + mode: ContinuousDeployment + update-build-number: false + branches: + main: + increment: Minor + pre-release-weight: 42 + """; + + var result = this.migrationService.Migrate(input); + + result.ShouldContain("workflow: GitHubFlow/v1"); + result.ShouldContain("mode: ContinuousDeployment"); + result.ShouldContain("update-build-number: false"); + result.ShouldContain("increment: Minor"); + result.ShouldContain("pre-release-weight: 42"); + result.ShouldNotContain("tag-prefix:"); + } + + [Test] + public void RejectsMixedConfiguration() + { + const string input = """ + calculation: {} + tag-prefix: custom- + """; + + Should.Throw(() => this.migrationService.Migrate(input)); + } + + [Test] + public void RejectsMalformedYaml() + { + const string input = "branches: ["; + + Should.Throw(() => this.migrationService.Migrate(input)); + } +} diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs index 3f0a1b4a7f..894e807eac 100644 --- a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs @@ -440,14 +440,14 @@ public void VerifyAliases() } [Test] - public void NoWarnOnGitVersionYmlFile() + public void WarnsOnceWhenExplicitV6LoadsAConfigurationFile() { const string text = ""; using var _ = this.fileSystem.SetupConfigFile(path: this.repoPath, text: text); - var stringLogger = string.Empty; + var logMessages = new List(); - var loggerFactory = new TestLoggerFactory(message => stringLogger = message); + var loggerFactory = new TestLoggerFactory(logMessages.Add); var options = Options.Create(new GitVersionOptions { WorkingDirectory = this.repoPath }); var sp = ConfigureServices(services => @@ -457,10 +457,77 @@ public void NoWarnOnGitVersionYmlFile() }); this.configurationProvider = (ConfigurationProvider)sp.GetRequiredService(); + this.configurationProvider.ProvideForDirectory(this.repoPath); this.configurationProvider.ProvideForDirectory(this.repoPath); var filePath = FileSystemHelper.Path.Combine(this.repoPath, ConfigurationFileLocator.DefaultFileName); - stringLogger.ShouldContain($"Using configuration file '{filePath}'"); + logMessages.ShouldContain(message => message.Contains($"Configuration file '{filePath}' uses the temporary v6 compatibility mode.", StringComparison.Ordinal)); + logMessages.Count(message => message.Contains("temporary v6 compatibility mode", StringComparison.Ordinal)).ShouldBe(1); + logMessages.ShouldContain(message => message.Contains("GitVersion 7.1", StringComparison.Ordinal)); + logMessages.ShouldContain(message => message.Contains("gitversion config migrate", StringComparison.Ordinal)); + } + + [TestCase(null)] + [TestCase("v7")] + public void DoesNotWarnWhenV6IsNotExplicitlySelected(string? configurationVersion) + { + System.Environment.SetEnvironmentVariable(ConfigurationVersionSelector.EnvironmentVariableName, configurationVersion); + using var _ = this.fileSystem.SetupConfigFile(path: this.repoPath, text: ""); + var logMessages = new List(); + var loggerFactory = new TestLoggerFactory(logMessages.Add); + var options = Options.Create(new GitVersionOptions { WorkingDirectory = this.repoPath }); + var sp = ConfigureServices(services => + { + services.AddSingleton(options); + loggerFactory.RegisterWith(services); + }); + this.configurationProvider = (ConfigurationProvider)sp.GetRequiredService(); + + this.configurationProvider.ProvideForDirectory(this.repoPath); + + logMessages.ShouldNotContain(message => message.Contains("temporary v6 compatibility mode", StringComparison.Ordinal)); + } + + [Test] + public void DoesNotWarnForExplicitV6BuiltInDefaults() + { + var logMessages = new List(); + var loggerFactory = new TestLoggerFactory(logMessages.Add); + var options = Options.Create(new GitVersionOptions { WorkingDirectory = this.repoPath }); + var sp = ConfigureServices(services => + { + services.AddSingleton(options); + loggerFactory.RegisterWith(services); + }); + this.configurationProvider = (ConfigurationProvider)sp.GetRequiredService(); + + this.configurationProvider.ProvideForDirectory(this.repoPath); + + logMessages.ShouldNotContain(message => message.Contains("temporary v6 compatibility mode", StringComparison.Ordinal)); + } + + [Test] + public void DoesNotWarnWhenExplicitV6ConfigurationFailsNormalization() + { + const string text = """ + calculation: + next-version: 2.0.0 + output: {} + """; + using var _ = this.fileSystem.SetupConfigFile(path: this.repoPath, text: text); + var logMessages = new List(); + var loggerFactory = new TestLoggerFactory(logMessages.Add); + var options = Options.Create(new GitVersionOptions { WorkingDirectory = this.repoPath }); + var sp = ConfigureServices(services => + { + services.AddSingleton(options); + loggerFactory.RegisterWith(services); + }); + this.configurationProvider = (ConfigurationProvider)sp.GetRequiredService(); + + Should.Throw(() => this.configurationProvider.ProvideForDirectory(this.repoPath)); + + logMessages.ShouldNotContain(message => message.Contains("temporary v6 compatibility mode", StringComparison.Ordinal)); } [Test] diff --git a/src/GitVersion.Configuration/ConfigurationDocumentMapper.cs b/src/GitVersion.Configuration/ConfigurationDocumentMapper.cs index 43a7d1de2c..2d945e355f 100644 --- a/src/GitVersion.Configuration/ConfigurationDocumentMapper.cs +++ b/src/GitVersion.Configuration/ConfigurationDocumentMapper.cs @@ -153,6 +153,34 @@ public static ConfigurationDocumentKind Detect(IReadOnlyDictionary Nest(IReadOnlyDictionary document) + { + Dictionary calculation = []; + Dictionary output = []; + + foreach (var (key, value) in document) + { + if (key is string propertyName && propertyName.Equals(BranchesPropertyName, StringComparison.Ordinal)) + { + SplitBranches(value, calculation, output); + } + else if (key is string outputPropertyName && OutputPropertyNames.Contains(outputPropertyName)) + { + output[key] = CloneValue(value); + } + else + { + calculation[key] = CloneValue(value); + } + } + + return new() + { + [CalculationSectionName] = calculation, + [OutputSectionName] = output + }; + } + public static bool IsOutputProperty(string propertyName) => OutputPropertyNames.Contains(propertyName); public static bool IsOutputBranchProperty(string propertyName) => OutputBranchPropertyNames.Contains(propertyName); @@ -286,6 +314,56 @@ private static void SplitBranches( } } + private static void SplitBranches( + object? value, + IDictionary calculation, + IDictionary output) + { + if (value is not IReadOnlyDictionary branches) + { + calculation[BranchesPropertyName] = CloneValue(value); + return; + } + + Dictionary calculationBranches = []; + Dictionary outputBranches = []; + foreach (var (branchName, branchValue) in branches) + { + if (branchValue is not IReadOnlyDictionary branch) + { + calculationBranches[branchName] = CloneValue(branchValue); + continue; + } + + Dictionary calculationBranch = []; + Dictionary outputBranch = []; + foreach (var (propertyName, propertyValue) in branch) + { + (propertyName is string name && OutputBranchPropertyNames.Contains(name) ? outputBranch : calculationBranch)[propertyName] = CloneValue(propertyValue); + } + + if (calculationBranch.Count != 0) + { + calculationBranches[branchName] = calculationBranch; + } + + if (outputBranch.Count != 0) + { + outputBranches[branchName] = outputBranch; + } + } + + if (calculationBranches.Count != 0) + { + calculation[BranchesPropertyName] = calculationBranches; + } + + if (outputBranches.Count != 0) + { + output[BranchesPropertyName] = outputBranches; + } + } + private static Dictionary CloneDictionary(IReadOnlyDictionary dictionary) => dictionary.ToDictionary(item => item.Key, item => CloneValue(item.Value)); diff --git a/src/GitVersion.Configuration/ConfigurationMigrationService.cs b/src/GitVersion.Configuration/ConfigurationMigrationService.cs new file mode 100644 index 0000000000..84413ea99b --- /dev/null +++ b/src/GitVersion.Configuration/ConfigurationMigrationService.cs @@ -0,0 +1,22 @@ +using GitVersion.Extensions; + +namespace GitVersion.Configuration; + +internal class ConfigurationMigrationService(IConfigurationSerializer configurationSerializer) : IConfigurationMigrationService +{ + private readonly IConfigurationSerializer configurationSerializer = configurationSerializer.NotNull(); + + public string Migrate(string input) + { + var document = this.configurationSerializer.Deserialize>(input); + return ConfigurationDocumentMapper.Detect(document) switch + { + ConfigurationDocumentKind.Empty or ConfigurationDocumentKind.V6 => + ConfigurationSerializer.SerializeDocument(ConfigurationDocumentMapper.Nest(document)), + ConfigurationDocumentKind.V7 => ConfigurationSerializer.SerializeDocument(document), + _ => throw new ConfigurationException( + "The configuration document mixes the v6 flat configuration structure with the v7 'calculation'/'output' structure. " + + "Use only one structure before migrating.") + }; + } +} diff --git a/src/GitVersion.Configuration/ConfigurationProvider.cs b/src/GitVersion.Configuration/ConfigurationProvider.cs index aa45b47a1f..cd6fdd10b5 100644 --- a/src/GitVersion.Configuration/ConfigurationProvider.cs +++ b/src/GitVersion.Configuration/ConfigurationProvider.cs @@ -18,6 +18,7 @@ internal class ConfigurationProvider( private readonly ILogger logger = logger.NotNull(); private readonly IConfigurationSerializer configurationSerializer = configurationSerializer.NotNull(); private readonly IOptions options = options.NotNull(); + private bool legacyConfigurationWarningLogged; public IGitVersionConfiguration Provide(IReadOnlyDictionary? overrideConfiguration = null) { @@ -49,6 +50,7 @@ private IGitVersionConfiguration ProvideConfiguration(string? configFile, var overrideConfigurationFromFile = configurationFromFile is null ? null : ConfigurationDocumentMapper.Normalize(configurationFromFile, configurationVersion, "configuration file"); + WarnAboutExplicitLegacyConfiguration(configFile, configurationFromFile); var normalizedOverrideConfiguration = overrideConfiguration is null ? null : ConfigurationDocumentMapper.NormalizeInternal(overrideConfiguration, "runtime override configuration"); @@ -102,6 +104,21 @@ private IGitVersionConfiguration ProvideConfiguration(string? configFile, return this.configurationSerializer.Deserialize>(content); } + private void WarnAboutExplicitLegacyConfiguration(string? configFilePath, Dictionary? configuration) + { + if (configuration is null || this.legacyConfigurationWarningLogged || !ConfigurationVersionSelector.IsExplicitV6()) + { + return; + } + + this.legacyConfigurationWarningLogged = true; + this.logger.LogWarning( + "Configuration file '{ConfigurationFile}' uses the temporary v6 compatibility mode. Legacy configuration loading is removed in GitVersion 7.1. " + + "Run 'gitversion config migrate' and validate with {ConfigurationVersion}=v7.", + configFilePath, + ConfigurationVersionSelector.EnvironmentVariableName); + } + private static string? GetWorkflow(IReadOnlyDictionary? overrideConfiguration, IReadOnlyDictionary? overrideConfigurationFromFile) { string? workflow = null; diff --git a/src/GitVersion.Configuration/ConfigurationSerializer.cs b/src/GitVersion.Configuration/ConfigurationSerializer.cs index d9159cf6ac..2cd8532913 100644 --- a/src/GitVersion.Configuration/ConfigurationSerializer.cs +++ b/src/GitVersion.Configuration/ConfigurationSerializer.cs @@ -47,6 +47,13 @@ public string Serialize(object graph) return YamlSerializer.Serialize(OrderProperties(configuration), SerializerOptions); } + internal static string SerializeDocument(IReadOnlyDictionary document) + { + var yaml = SerializeLegacy(document); + var configuration = YamlSerializer.Deserialize>(yaml, SerializerOptions) ?? []; + return YamlSerializer.Serialize(OrderProperties(configuration), SerializerOptions); + } + public static IGitVersionConfiguration? ReadConfiguration(string input) => DeserializeConfiguration(input, ConfigurationVersionSelector.Resolve(), "configuration document"); diff --git a/src/GitVersion.Configuration/GitVersionConfigurationModule.cs b/src/GitVersion.Configuration/GitVersionConfigurationModule.cs index c2b9b44134..f56fe973cc 100644 --- a/src/GitVersion.Configuration/GitVersionConfigurationModule.cs +++ b/src/GitVersion.Configuration/GitVersionConfigurationModule.cs @@ -8,6 +8,7 @@ public void RegisterTypes(IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/src/GitVersion.Core.Tests/Core/ConfigurationVersionSelectorTests.cs b/src/GitVersion.Core.Tests/Core/ConfigurationVersionSelectorTests.cs index 824adc42e5..6f85813a76 100644 --- a/src/GitVersion.Core.Tests/Core/ConfigurationVersionSelectorTests.cs +++ b/src/GitVersion.Core.Tests/Core/ConfigurationVersionSelectorTests.cs @@ -21,6 +21,17 @@ public void ResolvesKnownValues(string? value, bool isV6) ConfigurationVersionSelector.Resolve().ShouldBe(isV6 ? ConfigurationVersion.V6 : ConfigurationVersion.V7); } + [TestCase(null, false)] + [TestCase("v6", true)] + [TestCase(" V6 ", true)] + [TestCase("v7", false)] + public void IdentifiesExplicitV6Selection(string? value, bool expected) + { + using var scope = new EnvironmentVariableScope(value); + + ConfigurationVersionSelector.IsExplicitV6().ShouldBe(expected); + } + [TestCase("6")] [TestCase("7")] [TestCase("true")] diff --git a/src/GitVersion.Core/Configuration/ConfigurationVersion.cs b/src/GitVersion.Core/Configuration/ConfigurationVersion.cs index fc9710f637..e9a7db885f 100644 --- a/src/GitVersion.Core/Configuration/ConfigurationVersion.cs +++ b/src/GitVersion.Core/Configuration/ConfigurationVersion.cs @@ -25,4 +25,7 @@ _ when value.Equals("v7", StringComparison.OrdinalIgnoreCase) => ConfigurationVe } public static string ResolveName() => Resolve() == ConfigurationVersion.V6 ? "v6" : "v7"; + + public static bool IsExplicitV6() => + SysEnv.GetEnvironmentVariable(EnvironmentVariableName)?.Trim().Equals("v6", StringComparison.OrdinalIgnoreCase) ?? false; } diff --git a/src/GitVersion.Core/Configuration/IConfigurationMigrationService.cs b/src/GitVersion.Core/Configuration/IConfigurationMigrationService.cs new file mode 100644 index 0000000000..c940053b0f --- /dev/null +++ b/src/GitVersion.Core/Configuration/IConfigurationMigrationService.cs @@ -0,0 +1,6 @@ +namespace GitVersion.Configuration; + +internal interface IConfigurationMigrationService +{ + string Migrate(string input); +} diff --git a/src/GitVersion.Core/Options/ConfigurationMigrationInfo.cs b/src/GitVersion.Core/Options/ConfigurationMigrationInfo.cs new file mode 100644 index 0000000000..53673929a6 --- /dev/null +++ b/src/GitVersion.Core/Options/ConfigurationMigrationInfo.cs @@ -0,0 +1,20 @@ +namespace GitVersion; + +/// Settings that control a configuration migration operation. +public class ConfigurationMigrationInfo +{ + /// Gets or sets a value indicating whether the configuration migration command should be executed. + public bool IsMigration { get; set; } + + /// Gets or sets the configuration file to migrate. + public string? InputFile { get; set; } + + /// Gets or sets the file to which the migrated configuration should be written. + public string? OutputFile { get; set; } + + /// Gets or sets a value indicating whether the source configuration file should be replaced. + public bool InPlace { get; set; } + + /// Gets or sets a value indicating whether an existing output file may be replaced. + public bool Force { get; set; } +} diff --git a/src/GitVersion.Core/Options/GitVersionOptions.cs b/src/GitVersion.Core/Options/GitVersionOptions.cs index dd8efd81be..5bfa19b25f 100644 --- a/src/GitVersion.Core/Options/GitVersionOptions.cs +++ b/src/GitVersion.Core/Options/GitVersionOptions.cs @@ -17,6 +17,9 @@ public class GitVersionOptions /// Gets the settings that control how the GitVersion configuration file is located and applied. public ConfigurationInfo ConfigurationInfo { get; } = new(); + /// Gets the settings that control configuration migration. + public ConfigurationMigrationInfo ConfigurationMigrationInfo { get; } = new(); + /// Gets the repository-targeting settings (URL, branch, commit, clone path). public RepositoryInfo RepositoryInfo { get; } = new(); diff --git a/src/GitVersion.Core/PublicAPI.Unshipped.txt b/src/GitVersion.Core/PublicAPI.Unshipped.txt index 71b34a9b20..3f196a6d10 100644 --- a/src/GitVersion.Core/PublicAPI.Unshipped.txt +++ b/src/GitVersion.Core/PublicAPI.Unshipped.txt @@ -1,4 +1,17 @@ #nullable enable +GitVersion.ConfigurationMigrationInfo +GitVersion.ConfigurationMigrationInfo.ConfigurationMigrationInfo() -> void +GitVersion.ConfigurationMigrationInfo.Force.get -> bool +GitVersion.ConfigurationMigrationInfo.Force.set -> void +GitVersion.ConfigurationMigrationInfo.InPlace.get -> bool +GitVersion.ConfigurationMigrationInfo.InPlace.set -> void +GitVersion.ConfigurationMigrationInfo.InputFile.get -> string? +GitVersion.ConfigurationMigrationInfo.InputFile.set -> void +GitVersion.ConfigurationMigrationInfo.IsMigration.get -> bool +GitVersion.ConfigurationMigrationInfo.IsMigration.set -> void +GitVersion.ConfigurationMigrationInfo.OutputFile.get -> string? +GitVersion.ConfigurationMigrationInfo.OutputFile.set -> void +GitVersion.GitVersionOptions.ConfigurationMigrationInfo.get -> GitVersion.ConfigurationMigrationInfo! GitVersion.Configuration.EffectiveConfiguration.CustomVersionFormat.get -> string? GitVersion.Configuration.EffectiveConfiguration.VersionBumpResetMessage.get -> string? GitVersion.Configuration.IBranchConfiguration.CustomVersionFormat.get -> string?