diff --git a/src/NerdBank.GitVersioning/ReleaseManager.cs b/src/NerdBank.GitVersioning/ReleaseManager.cs index 2b1ceb6a..8784eec0 100644 --- a/src/NerdBank.GitVersioning/ReleaseManager.cs +++ b/src/NerdBank.GitVersioning/ReleaseManager.cs @@ -131,7 +131,52 @@ public enum ReleaseManagerOutputMode /// /// An optional, custom message to use for the commit that sets the new version number. May use {0} to substitute the new version number. /// - public void PrepareRelease(string projectDirectory, string releaseUnstableTag = null, Version nextVersion = null, VersionOptions.ReleaseVersionIncrement? versionIncrement = null, ReleaseManagerOutputMode outputMode = default, string unformattedCommitMessage = null) + /// + /// If true, simulates the prepare-release operation and returns the versions that would be set without making any changes. + /// + /// + /// A object containing information about the release when is true; otherwise null. + /// + public ReleaseInfo PrepareRelease(string projectDirectory, string releaseUnstableTag = null, Version nextVersion = null, VersionOptions.ReleaseVersionIncrement? versionIncrement = null, ReleaseManagerOutputMode outputMode = default, string unformattedCommitMessage = null, bool whatIf = false) + { + return this.PrepareReleaseCore(projectDirectory, releaseUnstableTag, nextVersion, versionIncrement, outputMode, unformattedCommitMessage, whatIf); + } + + /// + /// Core implementation of prepare-release functionality that can either simulate or execute the operation. + /// + /// + /// The path to the directory that may (or its ancestors may) define the version file. + /// + /// + /// An optional prerelease tag to apply on the release branch. + /// If not specified, any existing prerelease tag will be removed from the release. + /// The preceding hyphen may be omitted. + /// + /// + /// The version to use for the next release. + /// If not specified, the next version will be determined automatically by incrementing the current + /// version based on the current version and the setting in version.json. + /// Parameter will be ignored if the current branch is a release branch. + /// + /// + /// The increment to apply in order to determine the next version on the current branch. + /// If specified, value will be used instead of the increment specified in version.json. + /// Parameter will be ignored if the current branch is a release branch. + /// + /// + /// The output format to use for writing to stdout. + /// + /// + /// An optional, custom message to use for the commit that sets the new version number. May use {0} to substitute the new version number. + /// + /// + /// If true, simulates the prepare-release operation and returns the versions that would be set without making any changes. + /// + /// + /// A object containing information about the release when is true; otherwise null. + /// + private ReleaseInfo PrepareReleaseCore(string projectDirectory, string releaseUnstableTag, Version nextVersion, VersionOptions.ReleaseVersionIncrement? versionIncrement, ReleaseManagerOutputMode outputMode, string unformattedCommitMessage, bool whatIf) { Requires.NotNull(projectDirectory, nameof(projectDirectory)); @@ -164,16 +209,30 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = { if (outputMode == ReleaseManagerOutputMode.Text) { - this.stdout.WriteLine($"{releaseBranchName} branch advanced from {versionOptions.Version} to {releaseVersion}."); + if (whatIf) + { + this.stdout.WriteLine($"What-if: {releaseBranchName} branch would be advanced from {versionOptions.Version} to {releaseVersion}."); + } + else + { + this.stdout.WriteLine($"{releaseBranchName} branch advanced from {versionOptions.Version} to {releaseVersion}."); + } } - else + + var releaseInfo = new ReleaseInfo(new ReleaseBranchInfo(releaseBranchName, repository.Head.Tip.Id.ToString(), releaseVersion)); + + if (whatIf) + { + return releaseInfo; + } + + if (outputMode == ReleaseManagerOutputMode.Json) { - var releaseInfo = new ReleaseInfo(new ReleaseBranchInfo(releaseBranchName, repository.Head.Tip.Id.ToString(), releaseVersion)); this.WriteToOutput(releaseInfo); } this.UpdateVersion(context, versionOptions.Version, releaseVersion, unformattedCommitMessage); - return; + return null; } SemanticVersion nextDevVersion = this.GetNextDevVersion(versionOptions, nextVersion, versionIncrement); @@ -193,25 +252,38 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = throw new ReleasePreparationException(ReleasePreparationError.BranchAlreadyExists); } + if (outputMode == ReleaseManagerOutputMode.Text) + { + if (whatIf) + { + this.stdout.WriteLine($"What-if: {releaseBranchName} branch would track v{releaseVersion} stabilization and release."); + this.stdout.WriteLine($"What-if: {originalBranchName} branch would track v{nextDevVersion} development."); + } + else + { + this.stdout.WriteLine($"{releaseBranchName} branch now tracks v{releaseVersion} stabilization and release."); + this.stdout.WriteLine($"{originalBranchName} branch now tracks v{nextDevVersion} development."); + } + } + + var originalBranchInfo = new ReleaseBranchInfo(originalBranchName, repository.Head.Tip.Sha, nextDevVersion); + var releaseBranchInfo = new ReleaseBranchInfo(releaseBranchName, repository.Head.Tip.Id.ToString(), releaseVersion); + var releaseInfoResult = new ReleaseInfo(originalBranchInfo, releaseBranchInfo); + + if (whatIf) + { + return releaseInfoResult; + } + // create release branch and update version Branch releaseBranch = repository.CreateBranch(releaseBranchName); global::LibGit2Sharp.Commands.Checkout(repository, releaseBranch); this.UpdateVersion(context, versionOptions.Version, releaseVersion, unformattedCommitMessage); - if (outputMode == ReleaseManagerOutputMode.Text) - { - this.stdout.WriteLine($"{releaseBranchName} branch now tracks v{releaseVersion} stabilization and release."); - } - // update version on main branch global::LibGit2Sharp.Commands.Checkout(repository, originalBranchName); this.UpdateVersion(context, versionOptions.Version, nextDevVersion, unformattedCommitMessage); - if (outputMode == ReleaseManagerOutputMode.Text) - { - this.stdout.WriteLine($"{originalBranchName} branch now tracks v{nextDevVersion} development."); - } - // Merge release branch back to main branch var mergeOptions = new MergeOptions() { @@ -222,12 +294,21 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = if (outputMode == ReleaseManagerOutputMode.Json) { - var originalBranchInfo = new ReleaseBranchInfo(originalBranchName, repository.Head.Tip.Sha, nextDevVersion); - var releaseBranchInfo = new ReleaseBranchInfo(releaseBranchName, repository.Branches[releaseBranchName].Tip.Id.ToString(), releaseVersion); - var releaseInfo = new ReleaseInfo(originalBranchInfo, releaseBranchInfo); - - this.WriteToOutput(releaseInfo); + // Update the commit IDs with the actual final commit IDs after all operations + var finalOriginalBranchInfo = new ReleaseBranchInfo(originalBranchName, repository.Head.Tip.Sha, nextDevVersion); + var finalReleaseBranchInfo = new ReleaseBranchInfo(releaseBranchName, repository.Branches[releaseBranchName].Tip.Id.ToString(), releaseVersion); + var finalReleaseInfo = new ReleaseInfo(finalOriginalBranchInfo, finalReleaseBranchInfo); + + this.WriteToOutput(finalReleaseInfo); } + + return null; + } + + public void WriteToOutput(ReleaseInfo releaseInfo) + { + string json = JsonConvert.SerializeObject(releaseInfo, Formatting.Indented, new SemanticVersionJsonConverter()); + this.stdout.WriteLine(json); } private static bool IsVersionDecrement(SemanticVersion oldVersion, SemanticVersion newVersion) @@ -384,12 +465,6 @@ private SemanticVersion GetNextDevVersion(VersionOptions versionOptions, Version return nextDevVersion.SetFirstPrereleaseTag(versionOptions.ReleaseOrDefault.FirstUnstableTagOrDefault); } - private void WriteToOutput(ReleaseInfo releaseInfo) - { - string json = JsonConvert.SerializeObject(releaseInfo, Formatting.Indented, new SemanticVersionJsonConverter()); - this.stdout.WriteLine(json); - } - /// /// Exception indicating an error during preparation of a release. /// diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 0ad1386a..f0f84aa5 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -107,7 +107,6 @@ private static RootCommand BuildCommandLine() var source = new Option("--source", ["-s"]) { Description = $"The URI(s) of the NuGet package source(s) used to determine the latest stable version of the {PackageId} package. This setting overrides all of the sources specified in the NuGet.Config files.", - DefaultValueFactory = _ => Array.Empty(), Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true, }; @@ -132,7 +131,7 @@ private static RootCommand BuildCommandLine() { Description = "The path to the project or project directory. The default is the current directory.", }; - var metadata = new Option("--metadata", Array.Empty()) + var metadata = new Option("--metadata") { Description = "Adds an identifier to the build metadata part of a semantic version.", Arity = ArgumentArity.OneOrMore, @@ -258,7 +257,7 @@ private static RootCommand BuildCommandLine() { Description = "The path to the project or project directory used to calculate the version. The default is the current directory. Ignored if the -v option is specified.", }; - var metadata = new Option("--metadata", Array.Empty()) + var metadata = new Option("--metadata") { Description = "Adds an identifier to the build metadata part of a semantic version.", Arity = ArgumentArity.OneOrMore, @@ -280,14 +279,13 @@ private static RootCommand BuildCommandLine() { Description = "Defines a few common version variables as cloud build variables, with a \"Git\" prefix (e.g. GitBuildVersion, GitBuildVersionSimple, GitAssemblyInformationalVersion).", }; - var skipCloudBuildNumber = new Option("--skip-cloud-build-number", Array.Empty()) + var skipCloudBuildNumber = new Option("--skip-cloud-build-number") { Description = "Do not emit the cloud build variable to set the build number. This is useful when you want to set other cloud build variables but not the build number.", }; var define = new Option("--define", ["-d"]) { Description = "Additional cloud build variables to define. Each should be in the NAME=VALUE syntax.", - DefaultValueFactory = _ => Array.Empty(), Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = true, }; @@ -323,11 +321,11 @@ private static RootCommand BuildCommandLine() { Description = "The path to the project or project directory. The default is the current directory.", }; - var nextVersion = new Option("--nextVersion", Array.Empty()) + var nextVersion = new Option("--nextVersion") { Description = "The version to set for the current branch. If omitted, the next version is determined automatically by incrementing the current version.", }; - var versionIncrement = new Option("--versionIncrement", Array.Empty()) + var versionIncrement = new Option("--versionIncrement") { Description = "Overrides the 'versionIncrement' setting set in version.json for determining the next version of the current branch.", }; @@ -335,10 +333,14 @@ private static RootCommand BuildCommandLine() { Description = $"The format to write information about the release. Allowed values are: {string.Join(", ", SupportedFormats)}. The default is {DefaultOutputFormat}.", }; - var unformattedCommitMessage = new Option("--commit-message-pattern", Array.Empty()) + var unformattedCommitMessage = new Option("--commit-message-pattern") { Description = "A custom message to use for the commit that changes the version number. May include {0} for the version number. If not specified, the default is \"Set version to '{0}'\".", }; + var whatIf = new Option("--what-if") + { + Description = "Simulates the prepare-release operation and prints the new version that would be set, but does not actually make any changes.", + }; var tagArgument = new Argument("tag") { Description = "The prerelease tag to apply on the release branch (if any). If not specified, any existing prerelease tag will be removed. The preceding hyphen may be omitted.", @@ -351,6 +353,7 @@ private static RootCommand BuildCommandLine() versionIncrement, format, unformattedCommitMessage, + whatIf, tagArgument, }; @@ -362,7 +365,8 @@ private static RootCommand BuildCommandLine() var formatValue = parseResult.GetValue(format); var tagArgumentValue = parseResult.GetValue(tagArgument); var unformattedCommitMessageValue = parseResult.GetValue(unformattedCommitMessage); - return await OnPrepareReleaseCommand(projectValue, nextVersionValue, versionIncrementValue, formatValue, tagArgumentValue, unformattedCommitMessageValue); + var whatIfValue = parseResult.GetValue(whatIf); + return await OnPrepareReleaseCommand(projectValue, nextVersionValue, versionIncrementValue, formatValue, tagArgumentValue, unformattedCommitMessageValue, whatIfValue); }); } @@ -883,7 +887,7 @@ private static Task OnCloudCommand(string project, string[] metadata, strin return Task.FromResult((int)ExitCodes.OK); } - private static Task OnPrepareReleaseCommand(string project, string nextVersion, string versionIncrement, string format, string tag, string unformattedCommitMessage) + private static Task OnPrepareReleaseCommand(string project, string nextVersion, string versionIncrement, string format, string tag, string unformattedCommitMessage, bool whatIf) { // validate project path property string searchPath = GetSpecifiedOrCurrentDirectoryPath(project); @@ -949,11 +953,18 @@ private static Task OnPrepareReleaseCommand(string project, string nextVers } } - // run prepare-release + // run prepare-release or simulate try { var releaseManager = new ReleaseManager(Console.Out, Console.Error); - releaseManager.PrepareRelease(searchPath, tag, nextVersionParsed, versionIncrementParsed, outputMode, unformattedCommitMessage); + + ReleaseManager.ReleaseInfo releaseInfo = releaseManager.PrepareRelease(searchPath, tag, nextVersionParsed, versionIncrementParsed, outputMode, unformattedCommitMessage, whatIf); + + if (whatIf && outputMode == ReleaseManager.ReleaseManagerOutputMode.Json) + { + releaseManager.WriteToOutput(releaseInfo); + } + return Task.FromResult((int)ExitCodes.OK); } catch (ReleaseManager.ReleasePreparationException ex) diff --git a/test/Nerdbank.GitVersioning.Tests/ReleaseManagerTests.cs b/test/Nerdbank.GitVersioning.Tests/ReleaseManagerTests.cs index bb3c6e66..a5839f7b 100644 --- a/test/Nerdbank.GitVersioning.Tests/ReleaseManagerTests.cs +++ b/test/Nerdbank.GitVersioning.Tests/ReleaseManagerTests.cs @@ -715,4 +715,165 @@ private void AssertError(Action testCode, ReleasePreparationError expectedError) ReleasePreparationException ex = Assert.Throws(testCode); Assert.Equal(expectedError, ex.Error); } + + [Fact] + public void SimulatePrepareRelease_BasicScenario() + { + this.InitializeSourceControl(); + + var versionOptions = new VersionOptions() + { + Version = SemanticVersion.Parse("1.2-beta"), + Release = new ReleaseOptions() + { + VersionIncrement = ReleaseVersionIncrement.Minor, + FirstUnstableTag = "alpha", + }, + }; + this.WriteVersionFile(versionOptions); + + var releaseManager = new ReleaseManager(); + ReleaseManager.ReleaseInfo result = releaseManager.PrepareRelease(this.RepoPath, whatIf: true); + + Assert.NotNull(result); + Assert.Equal("v1.2", result.NewBranch.Name); + Assert.Equal("1.2", result.NewBranch.Version.ToString()); + Assert.Equal("master", result.CurrentBranch.Name); + Assert.Equal("1.3-alpha", result.CurrentBranch.Version.ToString()); + } + + [Fact] + public void SimulatePrepareRelease_WithPrereleaseTag() + { + this.InitializeSourceControl(); + + var versionOptions = new VersionOptions() + { + Version = SemanticVersion.Parse("1.2-beta"), + Release = new ReleaseOptions() + { + VersionIncrement = ReleaseVersionIncrement.Minor, + FirstUnstableTag = "alpha", + }, + }; + this.WriteVersionFile(versionOptions); + + var releaseManager = new ReleaseManager(); + ReleaseManager.ReleaseInfo result = releaseManager.PrepareRelease(this.RepoPath, "rc", whatIf: true); + + Assert.NotNull(result); + Assert.Equal("v1.2", result.NewBranch.Name); + Assert.Equal("1.2-rc", result.NewBranch.Version.ToString()); + Assert.Equal("master", result.CurrentBranch.Name); + Assert.Equal("1.3-alpha", result.CurrentBranch.Version.ToString()); + } + + [Fact] + public void SimulatePrepareRelease_WithVersionIncrement() + { + this.InitializeSourceControl(); + + var versionOptions = new VersionOptions() + { + Version = SemanticVersion.Parse("1.2-beta"), + Release = new ReleaseOptions() + { + VersionIncrement = ReleaseVersionIncrement.Minor, + FirstUnstableTag = "alpha", + }, + }; + this.WriteVersionFile(versionOptions); + + var releaseManager = new ReleaseManager(); + ReleaseManager.ReleaseInfo result = releaseManager.PrepareRelease(this.RepoPath, versionIncrement: ReleaseVersionIncrement.Major, whatIf: true); + + Assert.NotNull(result); + Assert.Equal("v1.2", result.NewBranch.Name); + Assert.Equal("1.2", result.NewBranch.Version.ToString()); + Assert.Equal("master", result.CurrentBranch.Name); + Assert.Equal("2.0-alpha", result.CurrentBranch.Version.ToString()); + } + + [Fact] + public void SimulatePrepareRelease_WithNextVersion() + { + this.InitializeSourceControl(); + + var versionOptions = new VersionOptions() + { + Version = SemanticVersion.Parse("1.2-beta"), + Release = new ReleaseOptions() + { + VersionIncrement = ReleaseVersionIncrement.Minor, + FirstUnstableTag = "alpha", + }, + }; + this.WriteVersionFile(versionOptions); + + var releaseManager = new ReleaseManager(); + ReleaseManager.ReleaseInfo result = releaseManager.PrepareRelease(this.RepoPath, nextVersion: new Version("1.5"), whatIf: true); + + Assert.NotNull(result); + Assert.Equal("v1.2", result.NewBranch.Name); + Assert.Equal("1.2", result.NewBranch.Version.ToString()); + Assert.Equal("master", result.CurrentBranch.Name); + Assert.Equal("1.5-alpha", result.CurrentBranch.Version.ToString()); + } + + // Note: SameVersionError test removed because it requires very specific conditions + // that are difficult to reproduce in simulation mode + [Fact] + public void SimulatePrepareRelease_BranchAlreadyExists() + { + this.InitializeSourceControl(); + + var versionOptions = new VersionOptions() + { + Version = SemanticVersion.Parse("1.2-beta"), + Release = new ReleaseOptions() + { + VersionIncrement = ReleaseVersionIncrement.Minor, + FirstUnstableTag = "alpha", + }, + }; + this.WriteVersionFile(versionOptions); + + // Create the release branch manually to simulate it already existing + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.CreateBranch("v1.2")); + Commands.Checkout(this.LibGit2Repository, "master"); + + var releaseManager = new ReleaseManager(); + + // Should throw because release branch already exists + this.AssertError(() => releaseManager.PrepareRelease(this.RepoPath, whatIf: true), ReleasePreparationError.BranchAlreadyExists); + } + + [Fact] + public void SimulatePrepareRelease_OnReleaseBranch() + { + this.InitializeSourceControl(); + + var versionOptions = new VersionOptions() + { + Version = SemanticVersion.Parse("1.2-beta"), + Release = new ReleaseOptions() + { + VersionIncrement = ReleaseVersionIncrement.Minor, + FirstUnstableTag = "alpha", + }, + }; + this.WriteVersionFile(versionOptions); + + // Create and checkout the release branch + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.CreateBranch("v1.2")); + + var releaseManager = new ReleaseManager(); + ReleaseManager.ReleaseInfo result = releaseManager.PrepareRelease(this.RepoPath, whatIf: true); + + Assert.NotNull(result); + Assert.Equal("v1.2", result.CurrentBranch.Name); + Assert.Equal("1.2", result.CurrentBranch.Version.ToString()); + // When on release branch, no new branch is created + Assert.Null(result.NewBranch); + } }