Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion src/NerdBank.GitVersioning/ReleaseManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,101 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag =
}
}

/// <summary>
/// Simulates the prepare-release operation and returns the versions that would be set without making any changes.
/// </summary>
/// <param name="projectDirectory">
/// The path to the directory that may (or its ancestors may) define the version file.
/// </param>
/// <param name="releaseUnstableTag">
/// 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.
/// </param>
/// <param name="nextVersion">
/// 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 <paramref name="versionIncrement"/> setting in <c>version.json</c>.
/// Parameter will be ignored if the current branch is a release branch.
/// </param>
/// <param name="versionIncrement">
/// 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 <c>version.json</c>.
/// Parameter will be ignored if the current branch is a release branch.
/// </param>
/// <param name="outputMode">
/// The output format to use for writing to stdout.
/// </param>
/// <returns>
/// A <see cref="ReleaseInfo"/> object containing information about the simulated release.
/// </returns>
public ReleaseInfo SimulatePrepareRelease(string projectDirectory, string releaseUnstableTag = null, Version nextVersion = null, VersionOptions.ReleaseVersionIncrement? versionIncrement = null, ReleaseManagerOutputMode outputMode = default)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Instead of creating a whole new method that duplicates a bunch of logic and needs to be kept in sync between the two methods, please add a parameter to the existing PrepareRelease method, or have these two methods call a 3rd private helper method that has most of the logic and takes a whatIf switch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I've refactored the code to use a single PrepareRelease method with a whatIf parameter instead of duplicating logic in a separate SimulatePrepareRelease method. The implementation now uses a private PrepareReleaseCore helper method that contains all the shared logic and takes the whatIf parameter to determine whether to actually make git changes or just simulate them. This eliminates code duplication and keeps the two code paths in sync automatically. Fixed in commit 28d04ed.

{
Requires.NotNull(projectDirectory, nameof(projectDirectory));

// open the git repository
LibGit2Context context = this.GetRepository(projectDirectory);
Repository repository = context.Repository;

if (repository.Info.IsHeadDetached)
{
this.stderr.WriteLine("Detached head. Check out a branch first.");
throw new ReleasePreparationException(ReleasePreparationError.DetachedHead);
}

// get the current version
VersionOptions versionOptions = context.VersionFile.GetVersion();
if (versionOptions is null)
{
this.stderr.WriteLine($"Failed to load version file for directory '{projectDirectory}'.");
throw new ReleasePreparationException(ReleasePreparationError.NoVersionFile);
}

string releaseBranchName = this.GetReleaseBranchName(versionOptions);
string originalBranchName = repository.Head.FriendlyName;
SemanticVersion releaseVersion = string.IsNullOrEmpty(releaseUnstableTag)
? versionOptions.Version.WithoutPrepreleaseTags()
: versionOptions.Version.SetFirstPrereleaseTag(releaseUnstableTag);

// check if the current branch is the release branch
if (string.Equals(originalBranchName, releaseBranchName, StringComparison.OrdinalIgnoreCase))
{
if (outputMode == ReleaseManagerOutputMode.Text)
{
this.stdout.WriteLine($"What-if: {releaseBranchName} branch would be advanced from {versionOptions.Version} to {releaseVersion}.");
}

return new ReleaseInfo(new ReleaseBranchInfo(releaseBranchName, repository.Head.Tip.Id.ToString(), releaseVersion));
}

SemanticVersion nextDevVersion = this.GetNextDevVersion(versionOptions, nextVersion, versionIncrement);

// check if the current version on the current branch is different from the next version
// otherwise, both the release branch and the dev branch would have the same version
if (versionOptions.Version.Version == nextDevVersion.Version)
{
this.stderr.WriteLine($"Version on '{originalBranchName}' is already set to next version {nextDevVersion.Version}.");
throw new ReleasePreparationException(ReleasePreparationError.NoVersionIncrement);
}

// check if the release branch already exists
if (repository.Branches[releaseBranchName] is not null)
{
this.stderr.WriteLine($"Cannot create branch '{releaseBranchName}' because it already exists.");
throw new ReleasePreparationException(ReleasePreparationError.BranchAlreadyExists);
}

if (outputMode == ReleaseManagerOutputMode.Text)
{
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.");
}

var originalBranchInfo = new ReleaseBranchInfo(originalBranchName, repository.Head.Tip.Sha, nextDevVersion);
var releaseBranchInfo = new ReleaseBranchInfo(releaseBranchName, repository.Head.Tip.Id.ToString(), releaseVersion);
return new ReleaseInfo(originalBranchInfo, releaseBranchInfo);
}

private static bool IsVersionDecrement(SemanticVersion oldVersion, SemanticVersion newVersion)
{
if (newVersion.Version > oldVersion.Version)
Expand Down Expand Up @@ -384,7 +479,7 @@ private SemanticVersion GetNextDevVersion(VersionOptions versionOptions, Version
return nextDevVersion.SetFirstPrereleaseTag(versionOptions.ReleaseOrDefault.FirstUnstableTagOrDefault);
}

private void WriteToOutput(ReleaseInfo releaseInfo)
public void WriteToOutput(ReleaseInfo releaseInfo)
{
string json = JsonConvert.SerializeObject(releaseInfo, Formatting.Indented, new SemanticVersionJsonConverter());
this.stdout.WriteLine(json);
Expand Down
30 changes: 26 additions & 4 deletions src/nbgv/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,10 @@ private static RootCommand BuildCommandLine()
{
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<bool>("--what-if", Array.Empty<string>())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Let's drop the optional Array.Empty argument for the string[] parameter, both here and elsewhere in the file where it's used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I've removed all the optional Array.Empty<string>() arguments from the Option declarations throughout the Program.cs file, including the ones for metadata, versionIncrement, unformattedCommitMessage, whatIf, skipCloudBuildNumber, source, and define options. Fixed in commit 28d04ed.

{
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<string>("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.",
Expand All @@ -351,6 +355,7 @@ private static RootCommand BuildCommandLine()
versionIncrement,
format,
unformattedCommitMessage,
whatIf,
tagArgument,
};

Expand All @@ -362,7 +367,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);
});
}

Expand Down Expand Up @@ -883,7 +889,7 @@ private static Task<int> OnCloudCommand(string project, string[] metadata, strin
return Task.FromResult((int)ExitCodes.OK);
}

private static Task<int> OnPrepareReleaseCommand(string project, string nextVersion, string versionIncrement, string format, string tag, string unformattedCommitMessage)
private static Task<int> OnPrepareReleaseCommand(string project, string nextVersion, string versionIncrement, string format, string tag, string unformattedCommitMessage, bool whatIf)
{
// validate project path property
string searchPath = GetSpecifiedOrCurrentDirectoryPath(project);
Expand Down Expand Up @@ -949,11 +955,27 @@ private static Task<int> 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);

if (whatIf)
{
// Simulate the release without making changes
ReleaseManager.ReleaseInfo simulationResult = releaseManager.SimulatePrepareRelease(searchPath, tag, nextVersionParsed, versionIncrementParsed, outputMode);

if (outputMode == ReleaseManager.ReleaseManagerOutputMode.Json)
{
releaseManager.WriteToOutput(simulationResult);
}
}
else
{
// Actually perform the release
releaseManager.PrepareRelease(searchPath, tag, nextVersionParsed, versionIncrementParsed, outputMode, unformattedCommitMessage);
}

return Task.FromResult((int)ExitCodes.OK);
}
catch (ReleaseManager.ReleasePreparationException ex)
Expand Down
162 changes: 162 additions & 0 deletions test/Nerdbank.GitVersioning.Tests/ReleaseManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -715,4 +715,166 @@ private void AssertError(Action testCode, ReleasePreparationError expectedError)
ReleasePreparationException ex = Assert.Throws<ReleasePreparationException>(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.SimulatePrepareRelease(this.RepoPath);

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.SimulatePrepareRelease(this.RepoPath, "rc");

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.SimulatePrepareRelease(this.RepoPath, versionIncrement: ReleaseVersionIncrement.Major);

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.SimulatePrepareRelease(this.RepoPath, nextVersion: new Version("1.5"));

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.SimulatePrepareRelease(this.RepoPath), 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.SimulatePrepareRelease(this.RepoPath);

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);
}
}