From 364d9bbda58bac3fe3bdd91698330646cafec89d Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 19 Aug 2026 17:54:06 +0200 Subject: [PATCH 1/6] feat: add configuration migration service --- .../ConfigurationMigrationServiceTests.cs | 114 ++++++++++++++++++ .../ConfigurationMigrationService.cs | 58 +++++++++ .../ConfigurationSerializer.cs | 7 ++ .../GitVersionConfigurationModule.cs | 1 + .../IConfigurationMigrationService.cs | 6 + 5 files changed, 186 insertions(+) create mode 100644 src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs create mode 100644 src/GitVersion.Configuration/ConfigurationMigrationService.cs create mode 100644 src/GitVersion.Core/Configuration/IConfigurationMigrationService.cs diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs new file mode 100644 index 0000000000..b6de1275c5 --- /dev/null +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs @@ -0,0 +1,114 @@ +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.ShouldNotContain(" workflow:"); + 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 + workflow: GitHubFlow/v1 + calculation: + tag-prefix: custom- + """; + + var result = this.migrationService.Migrate(input); + + result.ShouldContain("workflow: GitHubFlow/v1"); + result.ShouldNotContain(" workflow:"); + this.migrationService.Migrate(result).ShouldBe(result); + } + + [TestCase("GitFlow/v1")] + [TestCase("GitHubFlow/v1")] + public void MigratesWorkflowOnlyConfigurationAtRoot(string workflow) + { + var result = this.migrationService.Migrate($"workflow: {workflow}"); + + result.ShouldContain($"workflow: {workflow}"); + result.ShouldNotContain(" workflow:"); + result.ShouldNotContain("tag-prefix:"); + this.migrationService.Migrate(result).ShouldBe(result); + } + + [Test] + public void MigratesDraftCalculationWorkflowToRootWithoutChangingOverrides() + { + const string input = """ + calculation: + workflow: GitHubFlow/v1 + tag-prefix: custom- + output: + update-build-number: false + """; + + var result = this.migrationService.Migrate(input); + + result.ShouldContain("workflow: GitHubFlow/v1"); + result.ShouldNotContain(" workflow:"); + result.ShouldContain(" tag-prefix: custom-"); + result.ShouldContain(" update-build-number: false"); + this.migrationService.Migrate(result).ShouldBe(result); + } + + [TestCase("GitHubFlow/v1")] + [TestCase("GitFlow/v1")] + public void RejectsDuplicateRootAndCalculationWorkflowsEvenWhenEqual(string nestedWorkflow) + { + var input = $"workflow: GitHubFlow/v1\ncalculation:\n workflow: {nestedWorkflow}"; + + var exception = Should.Throw(() => this.migrationService.Migrate(input)); + + exception.Message.ShouldContain("both the document root and 'calculation.workflow'"); + } + + [TestCase("output:\n workflow: GitHubFlow/v1")] + [TestCase("workflow: GitHubFlow/v1\noutput:\n workflow: GitHubFlow/v1")] + public void RejectsOutputWorkflow(string input) + { + var exception = Should.Throw(() => this.migrationService.Migrate(input)); + + exception.Message.ShouldContain("'output.workflow'"); + exception.Message.ShouldContain("document root"); + } + + [Test] + public void RejectsMixedConfiguration() + { + const string input = """ + calculation: {} + tag-prefix: custom- + """; + + Should.Throw(() => this.migrationService.Migrate(input)); + } +} diff --git a/src/GitVersion.Configuration/ConfigurationMigrationService.cs b/src/GitVersion.Configuration/ConfigurationMigrationService.cs new file mode 100644 index 0000000000..515a4a7be8 --- /dev/null +++ b/src/GitVersion.Configuration/ConfigurationMigrationService.cs @@ -0,0 +1,58 @@ +using GitVersion.Extensions; +using SharpYaml; + +namespace GitVersion.Configuration; + +internal class ConfigurationMigrationService(IConfigurationSerializer configurationSerializer) : IConfigurationMigrationService +{ + private static readonly YamlSerializerOptions ValidationOptions = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = [VersionStrategiesConverter.Instance] + }; + + private readonly IConfigurationSerializer configurationSerializer = configurationSerializer.NotNull(); + + public string Migrate(string input) + { + var document = this.configurationSerializer.Deserialize>(input); + MoveDraftWorkflowToRoot(document); + var normalized = ConfigurationDocumentMapper.NormalizeInternal(document, "configuration document"); + // Validate the effective types without serializing defaults back into the user's document. + _ = YamlSerializer.Deserialize(ConfigurationSerializer.SerializeLegacy(normalized), ValidationOptions); + + return ConfigurationDocumentMapper.Detect(document) switch + { + ConfigurationDocumentKind.Empty or ConfigurationDocumentKind.Shared 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.") + }; + } + + private static void MoveDraftWorkflowToRoot(Dictionary document) + { + if (document.TryGetValue(ConfigurationDocumentMapper.OutputSectionName, out var outputValue) + && outputValue is IReadOnlyDictionary output && output.ContainsKey(ConfigurationDocumentMapper.WorkflowPropertyName)) + { + throw new ConfigurationException("Configuration property 'output.workflow' is not supported. Move 'workflow' to the document root before migrating."); + } + + if (!document.TryGetValue(ConfigurationDocumentMapper.CalculationSectionName, out var calculationValue) + || calculationValue is not IDictionary calculation + || !calculation.TryGetValue(ConfigurationDocumentMapper.WorkflowPropertyName, out var workflow)) + { + return; + } + + if (document.ContainsKey(ConfigurationDocumentMapper.WorkflowPropertyName)) + { + throw new ConfigurationException("Configuration property 'workflow' is defined at both the document root and 'calculation.workflow'. Keep only one before migrating."); + } + + calculation.Remove(ConfigurationDocumentMapper.WorkflowPropertyName); + document[ConfigurationDocumentMapper.WorkflowPropertyName] = workflow; + } +} 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/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); +} From b0c4e802d15d6a8105b0d2f2d1c98d524127aceb Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 19 Aug 2026 18:06:19 +0200 Subject: [PATCH 2/6] feat: add config migration command --- .../ArgumentParserTests.cs | 41 ++++++ .../ConfigurationMigrationExecutorTests.cs | 39 ++++++ .../ConfigurationVersionIntegrationTests.cs | 129 ++++++++++++++++++ src/GitVersion.App.Tests/HelpWriterTests.cs | 7 +- src/GitVersion.App/ArgumentParser.cs | 68 ++++++++- src/GitVersion.App/Arguments.cs | 14 ++ .../ConfigurationMigrationExecutor.cs | 55 ++++++++ src/GitVersion.App/GitVersionApp.cs | 6 + src/GitVersion.App/GitVersionAppModule.cs | 1 + .../IConfigurationMigrationExecutor.cs | 6 + .../Options/ConfigurationMigrationInfo.cs | 20 +++ .../Options/GitVersionOptions.cs | 3 + src/GitVersion.Core/PublicAPI.Unshipped.txt | 13 ++ 13 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs create mode 100644 src/GitVersion.App/ConfigurationMigrationExecutor.cs create mode 100644 src/GitVersion.App/IConfigurationMigrationExecutor.cs create mode 100644 src/GitVersion.Core/Options/ConfigurationMigrationInfo.cs diff --git a/src/GitVersion.App.Tests/ArgumentParserTests.cs b/src/GitVersion.App.Tests/ArgumentParserTests.cs index bfd495b02c..ccb951c17e 100644 --- a/src/GitVersion.App.Tests/ArgumentParserTests.cs +++ b/src/GitVersion.App.Tests/ArgumentParserTests.cs @@ -169,6 +169,47 @@ public void OverrideConfigBatchValidationDoesNotApplyAnyValues() parser.GetOverrideConfiguration().ShouldBeEmpty(); } + [TestCase("config migrate --config GitVersion.yml")] + [TestCase("config migrate -c GitVersion.yml")] + [TestCase("--config GitVersion.yml config migrate")] + [TestCase("-c GitVersion.yml config migrate")] + public void ConfigMigrateParsesItsInputAndOutputOptions(string inputArguments) + { + var arguments = this.argumentParser.ParseArguments( + $"{inputArguments} --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 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..fddb4b072e 100644 --- a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs +++ b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs @@ -5,8 +5,137 @@ 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); + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task ConfigMigrateDiscoversRootConfigurationUnlessWorkingDirectoryHasOne(bool hasLocalConfiguration) + { + using var repository = new EmptyRepositoryFixture(); + var rootConfiguration = Path.Combine(repository.RepositoryPath, ConfigurationFileLocator.DefaultFileName); + await File.WriteAllTextAsync(rootConfiguration, "next-version: 2.0.0"); + var subdirectory = Directory.CreateDirectory(Path.Combine(repository.RepositoryPath, "subdirectory")); + if (hasLocalConfiguration) + { + await File.WriteAllTextAsync(Path.Combine(subdirectory.FullName, ConfigurationFileLocator.DefaultFileName), "next-version: 3.0.0"); + } + + var result = await new ProgramFixture(subdirectory.FullName).Run("config", "migrate"); + + result.ExitCode.ShouldBe(0); + result.Output.ShouldNotBeNull(); + result.Output.ShouldContain(hasLocalConfiguration ? "next-version: 3.0.0" : "next-version: 2.0.0"); + (await File.ReadAllTextAsync(rootConfiguration)).ShouldBe("next-version: 2.0.0"); + } + + [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 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/ArgumentParser.cs b/src/GitVersion.App/ArgumentParser.cs index 2486f545cc..b4623607ff 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); @@ -135,6 +145,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) ?? parseResult.GetValue(options.Config), + 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 +489,18 @@ 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."); + configCommand.SetAction(_ => { }); + var migrate = new Command("migrate", "Migrates a v6 configuration document to the v7 structure."); + var migrationInputFile = new Option("--config", "-c") { 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 +531,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 +552,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 +679,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..af811d2842 --- /dev/null +++ b/src/GitVersion.App/ConfigurationMigrationExecutor.cs @@ -0,0 +1,55 @@ +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) + ?? this.configurationFileLocator.GetConfigurationFile(this.fileSystem.FindGitDir(options.WorkingDirectory)?.WorkingTreeDirectory) + : 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); + } + + this.fileSystem.File.WriteAllText(outputFile, migrated); + return 0; + } +} 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.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 84858afa1d..d2bf01e297 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? From 800d951b31ac4a8dfedf2f6ed843b2797e6e3437 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 19 Aug 2026 18:30:40 +0200 Subject: [PATCH 3/6] feat: warn when explicit v6 configuration is loaded --- .../ConfigurationVersionIntegrationTests.cs | 42 ++++++++++++++++++- .../Helpers/ExecutionResults.cs | 2 + .../Helpers/GitVersionHelper.cs | 32 ++++++++++++-- .../JsonOutputOnBuildServerTest.cs | 5 ++- .../ConfigurationMigrationExecutor.cs | 1 - .../ConfigurationProviderTests.cs | 36 ++++++++++++++-- .../ConfigurationProvider.cs | 17 ++++++++ .../Core/ConfigurationVersionSelectorTests.cs | 11 +++++ .../Configuration/ConfigurationVersion.cs | 3 ++ .../Extensions/ServiceCollectionExtensions.cs | 10 +++++ 10 files changed, 147 insertions(+), 12 deletions(-) diff --git a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs index fddb4b072e..2bd2ecc36f 100644 --- a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs +++ b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs @@ -155,7 +155,47 @@ public void V6AndV7ConfigurationCalculateTheSameVersion() v6Result.ExitCode.ShouldBe(0); v7Result.ExitCode.ShouldBe(0); - GetFullSemVer(v7Result.Output!).ShouldBe(GetFullSemVer(v6Result.Output!)); + GetFullSemVer(v7Result.StandardOutput!).ShouldBe(GetFullSemVer(v6Result.StandardOutput!)); + } + + [TestCase(false)] + [TestCase(true)] + public void ExplicitV6WarningUsesStandardErrorOnceWithoutContaminatingJson(bool logToFile) + { + using var fixture = new EmptyRepositoryFixture(); + fixture.MakeACommit(); + var configurationPath = Path.Combine(fixture.RepositoryPath, ConfigurationFileLocator.DefaultFileName); + File.WriteAllText(configurationPath, "next-version: 2.0.0"); + + var result = GitVersionHelper.ExecuteIn(fixture.RepositoryPath, " --no-cache", logToFile, + new KeyValuePair(ConfigurationVersionSelector.EnvironmentVariableName, "v6")); + + result.ExitCode.ShouldBe(0); + GetFullSemVer(result.StandardOutput!).ShouldBe("2.0.0-1"); + result.StandardError.ShouldNotBeNull(); + result.StandardError.Split("temporary v6 compatibility mode").Length.ShouldBe(2); + result.StandardError.ShouldContain(configurationPath); + result.StandardError.ShouldContain("GitVersion 7.1"); + result.StandardError.ShouldContain("gitversion config migrate"); + result.StandardError.ShouldContain("GITVERSION_CONFIGURATION_VERSION=v7"); + if (logToFile) + { + result.Log.ShouldNotBeNull(); + result.Log.ShouldContain("temporary v6 compatibility mode"); + } + } + + [Test] + public void ExplicitV6WithoutUserConfigurationDoesNotWarnOnStandardError() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.MakeACommit(); + + var result = Execute(fixture.RepositoryPath, "v6"); + + result.ExitCode.ShouldBe(0); + result.StandardError.ShouldBeEmpty(); + GetFullSemVer(result.StandardOutput!).ShouldNotBeNullOrEmpty(); } [TestCase("v6", false)] diff --git a/src/GitVersion.App.Tests/Helpers/ExecutionResults.cs b/src/GitVersion.App.Tests/Helpers/ExecutionResults.cs index 793d45850c..3a5fee85b6 100644 --- a/src/GitVersion.App.Tests/Helpers/ExecutionResults.cs +++ b/src/GitVersion.App.Tests/Helpers/ExecutionResults.cs @@ -9,6 +9,8 @@ public class ExecutionResults(int exitCode, string? output, string? logContents public int ExitCode { get; } = exitCode; public string? Output { get; } = output; public string? Log { get; init; } = logContents; + public string? StandardOutput { get; init; } + public string? StandardError { get; init; } public GitVersionVariables? OutputVariables { diff --git a/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs b/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs index 9694afabc3..30be3651e3 100644 --- a/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs +++ b/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs @@ -24,6 +24,8 @@ private static ExecutionResults ExecuteIn(ArgumentBuilder arguments, { var executable = ExecutableHelper.DotNetExecutable; var output = new StringBuilder(); + var standardOutput = new StringBuilder(); + var standardError = new StringBuilder(); var environmentalVariables = new Dictionary { @@ -53,8 +55,22 @@ private static ExecutionResults ExecuteIn(ArgumentBuilder arguments, var workingDirectory = arguments.WorkingDirectory ?? FileSystemHelper.Path.GetCurrentDirectory(); exitCode = ProcessHelper.Run( - s => output.AppendLine(s), - s => output.AppendLine(s), + s => + { + standardOutput.AppendLine(s); + lock (output) + { + output.AppendLine(s); + } + }, + s => + { + standardError.AppendLine(s); + lock (output) + { + output.AppendLine(s); + } + }, null, executable, args, @@ -79,7 +95,11 @@ private static ExecutionResults ExecuteIn(ArgumentBuilder arguments, if (arguments.LogFile.IsNullOrWhiteSpace() || !FileSystemHelper.File.Exists(arguments.LogFile)) { - return new(exitCode, output.ToString()); + return new(exitCode, output.ToString()) + { + StandardOutput = standardOutput.ToString(), + StandardError = standardError.ToString() + }; } var logContents = FileSystemHelper.File.ReadAllText(arguments.LogFile); @@ -90,6 +110,10 @@ private static ExecutionResults ExecuteIn(ArgumentBuilder arguments, Console.WriteLine(); Console.WriteLine("-------------------------------------------------------"); - return new(exitCode, output.ToString(), logContents); + return new(exitCode, output.ToString(), logContents) + { + StandardOutput = standardOutput.ToString(), + StandardError = standardError.ToString() + }; } } diff --git a/src/GitVersion.App.Tests/JsonOutputOnBuildServerTest.cs b/src/GitVersion.App.Tests/JsonOutputOnBuildServerTest.cs index 7bd904ce1a..0dca45ed2f 100644 --- a/src/GitVersion.App.Tests/JsonOutputOnBuildServerTest.cs +++ b/src/GitVersion.App.Tests/JsonOutputOnBuildServerTest.cs @@ -19,8 +19,9 @@ public void BeingOnBuildServerDoesntOverrideOutputJson() var result = GitVersionHelper.ExecuteIn(fixture.LocalRepositoryFixture.RepositoryPath, arguments: " --output json", environments: env); result.ExitCode.ShouldBe(0); - result.Output.ShouldStartWith("{"); - result.Output.TrimEnd().ShouldEndWith("}"); + result.StandardOutput.ShouldNotBeNull(); + using var json = JsonDocument.Parse(result.StandardOutput); + json.RootElement.GetProperty("FullSemVer").GetString().ShouldBe("0.0.1-5"); } [Test] diff --git a/src/GitVersion.App/ConfigurationMigrationExecutor.cs b/src/GitVersion.App/ConfigurationMigrationExecutor.cs index af811d2842..024830f16f 100644 --- a/src/GitVersion.App/ConfigurationMigrationExecutor.cs +++ b/src/GitVersion.App/ConfigurationMigrationExecutor.cs @@ -45,7 +45,6 @@ public int Execute(GitVersionOptions options) 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); } diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs index 42b771dc8a..d9879627d1 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,38 @@ 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)); + } + + [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/ConfigurationProvider.cs b/src/GitVersion.Configuration/ConfigurationProvider.cs index b52f2a6635..1e78c80939 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.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/Extensions/ServiceCollectionExtensions.cs b/src/GitVersion.Core/Extensions/ServiceCollectionExtensions.cs index 2132e50ab1..35fc6582f9 100644 --- a/src/GitVersion.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/GitVersion.Core/Extensions/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using GitVersion.Logging; using Serilog; using Serilog.Core; +using Serilog.Events; namespace GitVersion.Extensions; @@ -58,6 +59,15 @@ private static void ConfigureLogger(LoggerConfiguration loggerConfig, GitVersion { loggerConfig.WriteTo.Console(outputTemplate: outputTemplate, formatProvider: formatProvider); } + else + { + // Keep warnings visible without mixing them into machine-readable stdout. + // Errors already have an explicit stderr path in the CLI executors. + loggerConfig.WriteTo.Logger(warnings => warnings + .Filter.ByIncludingOnly(logEvent => logEvent.Level == LogEventLevel.Warning) + .WriteTo.Console(outputTemplate: outputTemplate, formatProvider: formatProvider, + standardErrorFromLevel: LogEventLevel.Warning)); + } if (ShouldLogToFile()) { From 7866efc7934a61190c68a4fad31d9458f27d275f Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 19 Aug 2026 19:54:31 +0200 Subject: [PATCH 4/6] fix: make configuration migration output atomic --- .../ConfigurationVersionIntegrationTests.cs | 47 +++++++++++++++++++ .../ConfigurationMigrationExecutor.cs | 21 ++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs index 2bd2ecc36f..bb83f6f3c8 100644 --- a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs +++ b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs @@ -87,6 +87,30 @@ public async Task ConfigMigrateDiscoversRootConfigurationUnlessWorkingDirectoryH (await File.ReadAllTextAsync(rootConfiguration)).ShouldBe("next-version: 2.0.0"); } + [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() { @@ -136,6 +160,29 @@ public async Task ConfigMigrateInPlaceUsesPositionalTargetPath() } } + [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/ConfigurationMigrationExecutor.cs b/src/GitVersion.App/ConfigurationMigrationExecutor.cs index 024830f16f..c34d420ce6 100644 --- a/src/GitVersion.App/ConfigurationMigrationExecutor.cs +++ b/src/GitVersion.App/ConfigurationMigrationExecutor.cs @@ -48,7 +48,26 @@ public int Execute(GitVersionOptions options) this.logger.LogWarning("Replacing '{ConfigurationFile}'. Comments cannot be preserved during migration.", outputFile); } - this.fileSystem.File.WriteAllText(outputFile, migrated); + 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); + } + } + } } From 72f9767d9f146237f5b16098a9c1c298352a71cc Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 19 Aug 2026 19:54:31 +0200 Subject: [PATCH 5/6] fix: reserve config migrate for POSIX parser --- src/GitVersion.App.Tests/ArgumentParserTests.cs | 9 +++++++++ src/GitVersion.App.Tests/LegacyArgumentParserTests.cs | 8 ++++++++ src/GitVersion.App/LegacyArgumentParser.cs | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/src/GitVersion.App.Tests/ArgumentParserTests.cs b/src/GitVersion.App.Tests/ArgumentParserTests.cs index ccb951c17e..9c8659160d 100644 --- a/src/GitVersion.App.Tests/ArgumentParserTests.cs +++ b/src/GitVersion.App.Tests/ArgumentParserTests.cs @@ -210,6 +210,15 @@ public void ConfigRequiresASubcommand() 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/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/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); From 02c47224de3421798f6958938d50a4006cedd607 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Wed, 19 Aug 2026 19:54:31 +0200 Subject: [PATCH 6/6] test: cover configuration migration compatibility --- .../ConfigurationMigrationExecutorTests.cs | 35 ++++++++ .../ConfigurationVersionIntegrationTests.cs | 89 ++++++++++++++++++- .../Helpers/GitVersionHelper.cs | 4 +- .../ConfigurationMigrationServiceTests.cs | 53 +++++++++++ .../ConfigurationProviderTests.cs | 39 ++++++++ 5 files changed, 214 insertions(+), 6 deletions(-) diff --git a/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs b/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs index 28bbc5e53b..c4e8a22945 100644 --- a/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs +++ b/src/GitVersion.App.Tests/ConfigurationMigrationExecutorTests.cs @@ -36,4 +36,39 @@ public void InPlaceMigrationWarnsThatCommentsCannotBePreserved() directory.Delete(recursive: true); } } + + [TestCase("workflow: GitHubFlow/v1\nnext-version: 2.0.0")] + [TestCase("calculation:\n workflow: GitHubFlow/v1\n next-version: 2.0.0")] + public void InPlaceMigrationWritesWorkflowAtRootAndIsIdempotent(string input) + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var inputFile = Path.Combine(directory.FullName, "GitVersion.yml"); + File.WriteAllText(inputFile, input); + var executor = new ConfigurationMigrationExecutor( + new FileSystem(), + new TestConsoleAdapter(new StringBuilder()), + new TestLogger(), + 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); + + var migrated = File.ReadAllText(inputFile); + migrated.ShouldContain("workflow: GitHubFlow/v1"); + migrated.ShouldNotContain(" workflow:"); + migrated.ShouldContain(" next-version: 2.0.0"); + executor.Execute(options).ShouldBe(0); + File.ReadAllText(inputFile).ShouldBe(migrated); + } + finally + { + directory.Delete(recursive: true); + } + } } diff --git a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs index bb83f6f3c8..711af30055 100644 --- a/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs +++ b/src/GitVersion.App.Tests/ConfigurationVersionIntegrationTests.cs @@ -55,8 +55,9 @@ public async Task ConfigMigrateDoesNotOverwriteOutputWithoutForce() logToFile: false); forceResult.ExitCode.ShouldBe(0); - forceResult.Output.ShouldNotBeNull(); - forceResult.Output.ShouldContain("Comments cannot be preserved during migration."); + forceResult.StandardError.ShouldNotBeNull(); + forceResult.StandardError.Split("Comments cannot be preserved during migration.").Length.ShouldBe(2); + forceResult.StandardOutput.ShouldBeEmpty(); var forcedOutput = await File.ReadAllTextAsync(outputPath); forcedOutput.ShouldContain("calculation:"); } @@ -111,6 +112,29 @@ public async Task ConfigMigrateLeavesNoTemporaryFileAfterWritingOutput() } } + [Test] + public async Task ConfigMigrateRejectsInvalidInputWithoutReplacingFile() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + const string input = "output:\n increment: Major"; + var configurationPath = Path.Combine(directory.FullName, ConfigurationFileLocator.DefaultFileName); + await File.WriteAllTextAsync(configurationPath, input); + + var result = await new ProgramFixture(directory.FullName).Run("config", "migrate", "--in-place"); + + result.ExitCode.ShouldBe(1); + result.Output.ShouldBeEmpty(); + (await File.ReadAllTextAsync(configurationPath)).ShouldBe(input); + Directory.GetFiles(directory.FullName).ShouldBe([configurationPath]); + } + finally + { + directory.Delete(recursive: true); + } + } + [Test] public async Task ConfigMigrateInPlaceMigratesExplicitConfigurationOutsideGitRepository() { @@ -127,8 +151,9 @@ public async Task ConfigMigrateInPlaceMigratesExplicitConfigurationOutsideGitRep logToFile: false); result.ExitCode.ShouldBe(0); - result.Output.ShouldNotBeNull(); - result.Output.ShouldContain("Comments cannot be preserved during migration."); + result.StandardError.ShouldNotBeNull(); + result.StandardError.Split("Comments cannot be preserved during migration.").Length.ShouldBe(2); + result.StandardOutput.ShouldBeEmpty(); var migratedConfiguration = await File.ReadAllTextAsync(configurationPath); migratedConfiguration.ShouldContain("calculation:"); } @@ -232,6 +257,62 @@ public void ExplicitV6WarningUsesStandardErrorOnceWithoutContaminatingJson(bool } } + [TestCase("v6", "")] + [TestCase("v7", "")] + [TestCase("v6", "workflow: GitHubFlow/v1")] + [TestCase("v7", "workflow: GitHubFlow/v1")] + public void CalculatesVersionWithSharedRootConfiguration(string version, string configuration) + { + using var fixture = new EmptyRepositoryFixture(); + fixture.MakeACommit(); + File.WriteAllText(Path.Combine(fixture.RepositoryPath, ConfigurationFileLocator.DefaultFileName), configuration); + + var result = Execute(fixture.RepositoryPath, version); + + result.ExitCode.ShouldBe(0); + GetFullSemVer(result.StandardOutput!).ShouldNotBeNullOrEmpty(); + } + + [TestCase(false)] + [TestCase(true)] + public void RootWorkflowAppliesDefaultsToBothSectionsAndPreservesOverrides(bool hasOverrides) + { + using var fixture = new EmptyRepositoryFixture(); + var configuration = "workflow: GitHubFlow/v1"; + if (hasOverrides) + { + configuration += """ + + calculation: + tag-prefix: custom- + branches: + main: + increment: Major + output: + assembly-versioning-scheme: None + branches: + main: + pre-release-weight: 42 + """; + } + File.WriteAllText(Path.Combine(fixture.RepositoryPath, ConfigurationFileLocator.DefaultFileName), configuration); + + var result = GitVersionHelper.ExecuteIn(fixture.RepositoryPath, " --show-config", logToFile: false, + new KeyValuePair(ConfigurationVersionSelector.EnvironmentVariableName, "v7")); + + result.ExitCode.ShouldBe(0); + result.Output.ShouldNotBeNull(); + result.Output.ShouldContain("workflow: GitHubFlow/v1"); + result.Output.ShouldNotContain(" workflow:"); + var document = new ConfigurationSerializer().Deserialize>(result.Output); + var effective = new ConfigurationHelper(ConfigurationDocumentMapper.Flatten(document)).Configuration; + effective.AssemblyFileVersioningScheme.ShouldBe(AssemblyFileVersioningScheme.MajorMinorPatch); + effective.TagPrefixPattern.ShouldBe(hasOverrides ? "custom-" : "[vV]?"); + effective.AssemblyVersioningScheme.ShouldBe(hasOverrides ? AssemblyVersioningScheme.None : AssemblyVersioningScheme.MajorMinorPatch); + effective.Branches["main"].Increment.ShouldBe(hasOverrides ? IncrementStrategy.Major : IncrementStrategy.Patch); + effective.Branches["main"].PreReleaseWeight.ShouldBe(hasOverrides ? 42 : 55000); + } + [Test] public void ExplicitV6WithoutUserConfigurationDoesNotWarnOnStandardError() { diff --git a/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs b/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs index 30be3651e3..fb8f98ac1e 100644 --- a/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs +++ b/src/GitVersion.App.Tests/Helpers/GitVersionHelper.cs @@ -57,17 +57,17 @@ private static ExecutionResults ExecuteIn(ArgumentBuilder arguments, exitCode = ProcessHelper.Run( s => { - standardOutput.AppendLine(s); lock (output) { + standardOutput.AppendLine(s); output.AppendLine(s); } }, s => { - standardError.AppendLine(s); lock (output) { + standardError.AppendLine(s); output.AppendLine(s); } }, diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs index b6de1275c5..6cd7a7203f 100644 --- a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationMigrationServiceTests.cs @@ -1,3 +1,5 @@ +using SharpYaml; + namespace GitVersion.Configuration.Tests; [TestFixture] @@ -101,6 +103,29 @@ public void RejectsOutputWorkflow(string input) exception.Message.ShouldContain("document root"); } + [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() { @@ -111,4 +136,32 @@ public void RejectsMixedConfiguration() Should.Throw(() => this.migrationService.Migrate(input)); } + + [Test] + public void RejectsMalformedYaml() + { + const string input = "branches: ["; + + Should.Throw(() => this.migrationService.Migrate(input)); + } + + [TestCase("update-build-number: not-a-boolean")] + [TestCase("output:\n update-build-number: not-a-boolean")] + [TestCase("branches:\n main:\n increment: Invalid")] + [TestCase("calculation:\n branches:\n main:\n increment: Invalid")] + [TestCase("unknown-setting: true")] + [TestCase("calculation:\n unknown-setting: true")] + public void RejectsInvalidSettings(string input) => + Should.Throw(() => this.migrationService.Migrate(input)); + + [TestCase("output:\n increment: Major", "calculation.increment")] + [TestCase("calculation:\n branches:\n main:\n pre-release-weight: 42", "output.branches..pre-release-weight")] + [TestCase("output: []", "must be a mapping")] + [TestCase("calculation:\n branches: []", "must be a mapping")] + public void RejectsInvalidNestedStructure(string input, string diagnostic) + { + var exception = Should.Throw(() => this.migrationService.Migrate(input)); + + exception.Message.ShouldContain(diagnostic); + } } diff --git a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs index d9879627d1..a154132dc9 100644 --- a/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs +++ b/src/GitVersion.Configuration.Tests/Configuration/ConfigurationProviderTests.cs @@ -467,6 +467,45 @@ public void WarnsOnceWhenExplicitV6LoadsAConfigurationFile() 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() {