diff --git a/GitTools.sln b/GitTools.sln
index d6b018b..466eb8a 100644
--- a/GitTools.sln
+++ b/GitTools.sln
@@ -80,6 +80,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.GitTool.Cli.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.GitTool.Cli.Commands", "source\GitTool\CreativeCoders.GitTool.Cli.Commands\CreativeCoders.GitTool.Cli.Commands.csproj", "{80C7112B-7D72-4585-BDFA-101289319B2C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.GitTool.Tests", "tests\CreativeCoders.GitTool.Tests\CreativeCoders.GitTool.Tests.csproj", "{EB39081F-6139-40B2-BEE7-E024DE6118CD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -136,6 +138,10 @@ Global
{80C7112B-7D72-4585-BDFA-101289319B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80C7112B-7D72-4585-BDFA-101289319B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80C7112B-7D72-4585-BDFA-101289319B2C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB39081F-6139-40B2-BEE7-E024DE6118CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB39081F-6139-40B2-BEE7-E024DE6118CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB39081F-6139-40B2-BEE7-E024DE6118CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB39081F-6139-40B2-BEE7-E024DE6118CD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -159,6 +165,7 @@ Global
{D65676E3-6820-464B-8493-597E3E1DCD98} = {90C87656-57A8-4AE9-8619-DA08AA137D6A}
{46156AAB-B9F6-4C99-AD0A-9F370DB87679} = {D65676E3-6820-464B-8493-597E3E1DCD98}
{80C7112B-7D72-4585-BDFA-101289319B2C} = {D65676E3-6820-464B-8493-597E3E1DCD98}
+ {EB39081F-6139-40B2-BEE7-E024DE6118CD} = {B7558045-D005-49D4-9205-55A32B6C99DE}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D38E5978-8FB3-474B-BDCB-18853E21A429}
diff --git a/build/Build.csproj b/build/Build.csproj
index 7ea113f..caf68a7 100644
--- a/build/Build.csproj
+++ b/build/Build.csproj
@@ -8,7 +8,7 @@
-
+
diff --git a/source/Git/CreativeCoders.Git.Abstractions/CreativeCoders.Git.Abstractions.csproj b/source/Git/CreativeCoders.Git.Abstractions/CreativeCoders.Git.Abstractions.csproj
index dced856..38dcfa6 100644
--- a/source/Git/CreativeCoders.Git.Abstractions/CreativeCoders.Git.Abstractions.csproj
+++ b/source/Git/CreativeCoders.Git.Abstractions/CreativeCoders.Git.Abstractions.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/source/Git/CreativeCoders.Git.Auth.CredentialManagerCore/CreativeCoders.Git.Auth.CredentialManagerCore.csproj b/source/Git/CreativeCoders.Git.Auth.CredentialManagerCore/CreativeCoders.Git.Auth.CredentialManagerCore.csproj
index 001eb62..9d8b465 100644
--- a/source/Git/CreativeCoders.Git.Auth.CredentialManagerCore/CreativeCoders.Git.Auth.CredentialManagerCore.csproj
+++ b/source/Git/CreativeCoders.Git.Auth.CredentialManagerCore/CreativeCoders.Git.Auth.CredentialManagerCore.csproj
@@ -5,8 +5,8 @@
-
-
+
+
diff --git a/source/Git/CreativeCoders.Git/CreativeCoders.Git.csproj b/source/Git/CreativeCoders.Git/CreativeCoders.Git.csproj
index 0457a51..4795f10 100644
--- a/source/Git/CreativeCoders.Git/CreativeCoders.Git.csproj
+++ b/source/Git/CreativeCoders.Git/CreativeCoders.Git.csproj
@@ -5,9 +5,9 @@
-
+
-
+
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/CreativeCoders.GitTool.Base.csproj b/source/GitTool/CreativeCoders.GitTool.Base/CreativeCoders.GitTool.Base.csproj
index 2a3f55e..613a962 100644
--- a/source/GitTool/CreativeCoders.GitTool.Base/CreativeCoders.GitTool.Base.csproj
+++ b/source/GitTool/CreativeCoders.GitTool.Base/CreativeCoders.GitTool.Base.csproj
@@ -5,9 +5,9 @@
-
-
-
+
+
+
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/ReturnCodes.cs b/source/GitTool/CreativeCoders.GitTool.Base/ReturnCodes.cs
index 6511294..be4b019 100644
--- a/source/GitTool/CreativeCoders.GitTool.Base/ReturnCodes.cs
+++ b/source/GitTool/CreativeCoders.GitTool.Base/ReturnCodes.cs
@@ -17,4 +17,8 @@ public static class ReturnCodes
public const int FeatureBranchAlreadyExistsLocal = -7;
public const int FeatureBranchAlreadyExistsRemote = -8;
+
+ public const int LocalTagNotFound = -9;
+
+ public const int ReleaseCreationAborted = -10;
}
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/GitRepositoryExtensions.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/GitRepositoryExtensions.cs
new file mode 100644
index 0000000..3cedf9f
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/GitRepositoryExtensions.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using CreativeCoders.Git.Abstractions;
+
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+public static class GitRepositoryExtensions
+{
+ public static IEnumerable GetVersionTags(this IGitRepository gitRepository)
+ {
+ foreach (var tag in gitRepository.Tags)
+ {
+ if (VersionUtils.IsValidVersion(tag.Name.Friendly, out var version))
+ {
+ yield return new VersionTag(version, tag);
+ }
+ }
+ }
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionBuilder.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionBuilder.cs
new file mode 100644
index 0000000..de4bf55
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionBuilder.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+public class VersionBuilder
+{
+ public const int MajorPartIndex = 0;
+ public const int MinorPartIndex = 1;
+ public const int PatchPartIndex = 2;
+
+ private readonly VersionFormatKind _formatKind;
+
+ private readonly List _versionParts = [];
+
+ public VersionBuilder(string version, VersionFormatKind formatKind = VersionFormatKind.Strict)
+ {
+ _formatKind = formatKind;
+ _versionParts.AddRange(SplitVersionParts(version));
+ }
+
+ private List SplitVersionParts(string version)
+ {
+ var versionParts = version.Split('.').ToList();
+
+ if (versionParts.Count > 3)
+ {
+ throw new VersionFormatException(version);
+ }
+
+ for (var i = 0; i < versionParts.Count; i++)
+ {
+ var versionPart = versionParts[i];
+
+ if (versionPart.Trim() != versionPart && _formatKind == VersionFormatKind.Strict)
+ {
+ throw new VersionFormatException(version,
+ $"The version part '{versionPart}' contains leading or trailing whitespace.");
+ }
+
+ if (!int.TryParse(versionPart.Trim(), out var versionPartInt))
+ {
+ throw new VersionFormatException(version, $"The version part '{versionPart}' is not a valid integer.");
+ }
+
+ versionParts[i] = versionPartInt.ToString();
+ }
+
+ while (versionParts.Count < 3)
+ {
+ versionParts.Add("0");
+ }
+
+ return versionParts;
+ }
+
+ private static string BuildVersion(IEnumerable versionParts)
+ {
+ return string.Join(".", versionParts);
+ }
+
+ public VersionBuilder IncrementPatch(int incrementBy = 1)
+ {
+ return IncrementPart(PatchPartIndex, incrementBy);
+ }
+
+ public VersionBuilder IncrementMinor(int incrementBy = 1, bool resetPatch = true)
+ {
+ IncrementPart(MinorPartIndex, incrementBy);
+
+ if (resetPatch)
+ {
+ Patch = 0;
+ }
+
+ return this;
+ }
+
+ public VersionBuilder IncrementMajor(int incrementBy = 1, bool resetMinorAndPatch = true)
+ {
+ IncrementPart(MajorPartIndex, incrementBy);
+
+ if (resetMinorAndPatch)
+ {
+ Minor = 0;
+ Patch = 0;
+ }
+
+ return this;
+ }
+
+ private VersionBuilder IncrementPart(int partIndex, int incrementBy)
+ {
+ if (!int.TryParse(_versionParts[partIndex], out var versionPartNumber))
+ {
+ throw new InvalidOperationException();
+ }
+
+ _versionParts[partIndex] = (versionPartNumber + incrementBy).ToString();
+
+ return this;
+ }
+
+ public string Build()
+ {
+ return BuildVersion(_versionParts);
+ }
+
+ public int GetVersionPart(int partIndex)
+ {
+ return partIndex is < 0 or > 2
+ ? throw new ArgumentOutOfRangeException(nameof(partIndex))
+ : int.Parse(_versionParts[partIndex]);
+ }
+
+ public int Major
+ {
+ get => GetVersionPart(MajorPartIndex);
+ set => _versionParts[MajorPartIndex] = value.ToString();
+ }
+
+ public int Minor
+ {
+ get => GetVersionPart(MinorPartIndex);
+ set => _versionParts[MinorPartIndex] = value.ToString();
+ }
+
+ public int Patch
+ {
+ get => GetVersionPart(PatchPartIndex);
+ set => _versionParts[PatchPartIndex] = value.ToString();
+ }
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionComparer.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionComparer.cs
new file mode 100644
index 0000000..96c0ee1
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionComparer.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+public class VersionComparer : IComparer
+{
+ public int Compare(string? x, string? y)
+ {
+ switch (x)
+ {
+ case null when y == null:
+ return 0;
+ case null:
+ return -1;
+ }
+
+ if (y == null)
+ {
+ return 1;
+ }
+
+ var versionBuilderX = new VersionBuilder(x);
+ var versionBuilderY = new VersionBuilder(y);
+
+ for (var i = 0; i < 3; i++)
+ {
+ var versionPartX = versionBuilderX.GetVersionPart(i);
+ var versionPartY = versionBuilderY.GetVersionPart(i);
+
+ var versionPartComparison = versionPartX.CompareTo(versionPartY);
+
+ if (versionPartComparison != 0)
+ {
+ return versionPartComparison;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionFormatException.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionFormatException.cs
new file mode 100644
index 0000000..7de9365
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionFormatException.cs
@@ -0,0 +1,11 @@
+using System;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+[PublicAPI]
+public class VersionFormatException(string? version, string? message = null) : Exception(message ??
+ $"The version '{version}' has an invalid format. The version must consist of 1 to 3 numeric parts separated by dots (e.g. '1.0.0').")
+{
+ public string? Version { get; } = version;
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionFormatKind.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionFormatKind.cs
new file mode 100644
index 0000000..4cd38e3
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionFormatKind.cs
@@ -0,0 +1,7 @@
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+public enum VersionFormatKind
+{
+ Strict,
+ Loose
+}
\ No newline at end of file
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionTag.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionTag.cs
new file mode 100644
index 0000000..45b8811
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionTag.cs
@@ -0,0 +1,13 @@
+using System.Diagnostics.CodeAnalysis;
+using CreativeCoders.Core;
+using CreativeCoders.Git.Abstractions.Tags;
+
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+[ExcludeFromCodeCoverage]
+public class VersionTag(string version, IGitTag tag)
+{
+ public string Version { get; } = Ensure.NotNull(version);
+
+ public IGitTag Tag { get; } = Ensure.NotNull(tag);
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionUtils.cs b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionUtils.cs
new file mode 100644
index 0000000..0573fbe
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Base/Versioning/VersionUtils.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Linq;
+
+namespace CreativeCoders.GitTool.Base.Versioning;
+
+public static class VersionUtils
+{
+ public static bool IsValidVersion(string version, out string normalizedVersion,
+ bool ignoreLeadingVersionPrefix = true)
+ {
+ if (ignoreLeadingVersionPrefix)
+ {
+ version = RemoveLeadingVersionPrefix(version);
+ }
+
+ var versionParts = version.Split('.');
+
+ var isValidVersion = versionParts.All(x => int.TryParse(x, out _));
+
+ normalizedVersion = isValidVersion ? string.Join(".", versionParts) : string.Empty;
+
+ return isValidVersion;
+ }
+
+ public static string RemoveLeadingVersionPrefix(string version)
+ {
+ if (string.IsNullOrEmpty(version))
+ {
+ return string.Empty;
+ }
+
+ string[] versionPrefixes = ["version", "v"];
+
+ var versionPrefix =
+ versionPrefixes.FirstOrDefault(x => version.StartsWith(x, StringComparison.OrdinalIgnoreCase));
+
+ return string.IsNullOrWhiteSpace(versionPrefix)
+ ? version
+ : version[versionPrefix.Length..];
+ }
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/CreativeCoders.GitTool.Cli.Commands.csproj b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/CreativeCoders.GitTool.Cli.Commands.csproj
index 6ec2d32..925be94 100644
--- a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/CreativeCoders.GitTool.Cli.Commands.csproj
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/CreativeCoders.GitTool.Cli.Commands.csproj
@@ -7,8 +7,8 @@
-
-
+
+
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseCommand.cs b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseCommand.cs
index 5944a16..7984d19 100644
--- a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseCommand.cs
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseCommand.cs
@@ -1,8 +1,11 @@
using CreativeCoders.Cli.Core;
+using CreativeCoders.Cli.Hosting.Exceptions;
using CreativeCoders.Core;
using CreativeCoders.Git.Abstractions;
using CreativeCoders.Git.Abstractions.Branches;
using CreativeCoders.GitTool.Base;
+using CreativeCoders.GitTool.Base.Versioning;
+using CreativeCoders.SysConsole.Core;
using JetBrains.Annotations;
using Spectre.Console;
@@ -13,63 +16,106 @@ namespace CreativeCoders.GitTool.Cli.Commands.ReleaseGroup.Create;
Description = "Creates a release by creating a version tag")]
public class CreateReleaseCommand(
IAnsiConsole ansiConsole,
- IGitServiceProviders gitServiceProviders,
IGitRepository gitRepository)
: ICliCommand
{
- private readonly IGitServiceProviders _gitServiceProviders = Ensure.NotNull(gitServiceProviders);
+ private const string DefaultBaseVersionForIncrement = "0.0.0";
private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole);
private readonly IGitRepository _gitRepository = Ensure.NotNull(gitRepository);
- private async Task MergeDevelopToMain(IGitRepository repository, string mainBranchName,
- CreateReleaseOptions options)
- {
- var provider = await _gitServiceProviders.GetServiceProviderAsync(repository, null);
-
- var createPullRequest = new GitCreatePullRequest(repository.Info.RemoteUri,
- $"Release {options.Version}", "develop", mainBranchName);
-
- _ = await provider.CreatePullRequestAsync(createPullRequest);
- }
-
public async Task ExecuteAsync(CreateReleaseOptions options)
{
var mainBranchName = GitBranchNames.Local.GetCanonicalName(_gitRepository.Info.MainBranch);
- if (_gitRepository.Branches["develop"] != null)
- {
- _ansiConsole.WriteLine(
- $"Repository has a develop branch. So first a merge from develop -> {mainBranchName} must be done.");
+ var version = CreateVersion(options);
- await MergeDevelopToMain(_gitRepository, mainBranchName, options);
+ if (options is { VersionIncrement: not null, NoConfirmAutoIncrementVersion: false })
+ {
+ _ansiConsole.MarkupLine($"Version will be incremented to '{version}'".ToInfoMarkup());
+
+ var prompt = new ConfirmationPrompt("Do you want to continue?")
+ {
+ DefaultValue = true
+ };
+
+ if (!await _ansiConsole.PromptAsync(prompt).ConfigureAwait(false))
+ {
+ throw new CliCommandAbortException("Release creation aborted.", ReturnCodes.ReleaseCreationAborted)
+ {
+ IsError = false
+ };
+ }
}
- var tagName = $"v{options.Version}";
+ var tagName = $"v{version}";
- _ansiConsole.WriteLine($"Create tag '{tagName}'");
+ _ansiConsole.WriteLine($"Creating tag '{tagName}'...");
_gitRepository.Branches.CheckOut(mainBranchName);
_gitRepository.Pull();
var versionTag =
- _gitRepository.Tags.CreateTagWithMessage(tagName, $"Version {options.Version}", mainBranchName);
+ _gitRepository.Tags.CreateTagWithMessage(tagName, $"Version {version}", mainBranchName);
+
+ _ansiConsole.MarkupLine($"Tag '{tagName}' created".ToSuccessMarkup());
if (options.PushAllTags)
{
- _ansiConsole.WriteLine("Push all tags to remote");
+ _ansiConsole.WriteLine("Pushing all tags to remote...");
_gitRepository.Tags.PushAllTags();
+
+ _ansiConsole.MarkupLine("All tags pushed successfully".ToSuccessMarkup());
}
else
{
- _ansiConsole.WriteLine($"Push tag '{versionTag.Name.Canonical}'");
+ _ansiConsole.WriteLine($"Pushing tag '{versionTag.Name.Canonical}'...");
_gitRepository.Tags.PushTag(versionTag);
+
+ _ansiConsole.MarkupLine($"Tag '{versionTag.Name.Canonical}' pushed successfully".ToSuccessMarkup());
}
return CommandResult.Success;
}
+
+ private string CreateVersion(CreateReleaseOptions options)
+ {
+ if (!string.IsNullOrWhiteSpace(options.Version))
+ {
+ return new VersionBuilder(options.Version).Build();
+ }
+
+ _gitRepository.FetchAllTags("origin");
+
+ var greatestVersion = _gitRepository
+ .GetVersionTags()
+ .OrderByDescending(x => x.Version, new VersionComparer())
+ .FirstOrDefault();
+
+ var versionBuilder =
+ new VersionBuilder(string.IsNullOrWhiteSpace(greatestVersion?.Version)
+ ? DefaultBaseVersionForIncrement
+ : greatestVersion.Version);
+
+ switch (options.VersionIncrement!)
+ {
+ case VersionAutoIncrement.Major:
+ versionBuilder.IncrementMajor(1, options.ResetLowerVersionPartsOnAutoInc);
+ break;
+ case VersionAutoIncrement.Minor:
+ versionBuilder.IncrementMinor(1, options.ResetLowerVersionPartsOnAutoInc);
+ break;
+ case VersionAutoIncrement.Patch:
+ versionBuilder.IncrementPatch();
+ break;
+ default:
+ throw new InvalidOperationException("Unknown VersionAutoIncrement");
+ }
+
+ return versionBuilder.Build();
+ }
}
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseOptions.cs b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseOptions.cs
index 9d37fb3..d37ebb0 100644
--- a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseOptions.cs
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/CreateReleaseOptions.cs
@@ -1,15 +1,42 @@
-using CreativeCoders.SysConsole.Cli.Parsing;
+using CreativeCoders.Cli.Core;
+using CreativeCoders.SysConsole.Cli.Parsing;
using JetBrains.Annotations;
namespace CreativeCoders.GitTool.Cli.Commands.ReleaseGroup.Create;
[PublicAPI]
-public class CreateReleaseOptions
+public class CreateReleaseOptions : IOptionsValidation
{
private const string PushAllTagsLongName = "alltags";
- [OptionValue(0, IsRequired = true)] public string? Version { get; set; }
+ [OptionValue(0, IsRequired = false, HelpText = "Version for release. If set version increment is not allowed.")]
+ public string? Version { get; set; }
[OptionParameter('a', PushAllTagsLongName)]
public bool PushAllTags { get; set; }
+
+ [OptionParameter('i', "increment", HelpText = "Version auto increment. If set version is not allowed.")]
+ public VersionAutoIncrement? VersionIncrement { get; set; }
+
+ [OptionParameter('r', "resetlower", HelpText = "Reset lower version parts on auto increment")]
+ public bool ResetLowerVersionPartsOnAutoInc { get; set; } = true;
+
+ [OptionParameter("nc", "noconfirm", HelpText = "No confirmation for auto increment version")]
+ public bool NoConfirmAutoIncrementVersion { get; set; }
+
+ public Task ValidateAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Version) && VersionIncrement == null)
+ {
+ return Task.FromResult(OptionsValidationResult.Invalid(["Version or version increment must be specified"]));
+ }
+
+ if (!string.IsNullOrWhiteSpace(Version) && VersionIncrement != null)
+ {
+ return Task.FromResult(
+ OptionsValidationResult.Invalid(["Version and version increment are mutually exclusive"]));
+ }
+
+ return Task.FromResult(OptionsValidationResult.Valid());
+ }
}
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/VersionAutoIncrement.cs b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/VersionAutoIncrement.cs
new file mode 100644
index 0000000..9a7170d
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/Create/VersionAutoIncrement.cs
@@ -0,0 +1,8 @@
+namespace CreativeCoders.GitTool.Cli.Commands.ReleaseGroup.Create;
+
+public enum VersionAutoIncrement
+{
+ Patch,
+ Minor,
+ Major
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/ListVersions/ListVersionsCommand.cs b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/ListVersions/ListVersionsCommand.cs
new file mode 100644
index 0000000..d81b06f
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/ListVersions/ListVersionsCommand.cs
@@ -0,0 +1,40 @@
+using CreativeCoders.Cli.Core;
+using CreativeCoders.Core;
+using CreativeCoders.Git.Abstractions;
+using CreativeCoders.Git.Abstractions.Tags;
+using CreativeCoders.GitTool.Base.Versioning;
+using CreativeCoders.SysConsole.Core;
+using Spectre.Console;
+
+namespace CreativeCoders.GitTool.Cli.Commands.ReleaseGroup.ListVersions;
+
+[CliCommand([ReleaseCommandGroup.Name, "list-versions"], Description = "Lists version tags")]
+public class ListVersionsCommand(IAnsiConsole ansiConsole, IGitRepository gitRepository)
+ : ICliCommand
+{
+ private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole);
+
+ private readonly IGitRepository _gitRepository = Ensure.NotNull(gitRepository);
+
+ public Task ExecuteAsync(ListVersionsOptions options)
+ {
+ _ansiConsole.WriteLines("Version tags:", string.Empty);
+
+ _gitRepository.FetchAllTags("origin");
+
+ var versionTags = _gitRepository.GetVersionTags();
+
+ var versionComparer = new VersionComparer();
+
+ var orderedVersionTags = options.SortDescending
+ ? versionTags.OrderByDescending(x => x.Version, versionComparer)
+ : versionTags.OrderBy(x => x.Version, versionComparer);
+
+ foreach (var versionTag in orderedVersionTags)
+ {
+ _ansiConsole.WriteLine($"- {versionTag.Version}: {versionTag.Tag.Name.Friendly}");
+ }
+
+ return Task.FromResult(CommandResult.Success);
+ }
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/ListVersions/ListVersionsOptions.cs b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/ListVersions/ListVersionsOptions.cs
new file mode 100644
index 0000000..f03b60d
--- /dev/null
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/ReleaseGroup/ListVersions/ListVersionsOptions.cs
@@ -0,0 +1,11 @@
+using CreativeCoders.SysConsole.Cli.Parsing;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.GitTool.Cli.Commands.ReleaseGroup.ListVersions;
+
+[UsedImplicitly]
+public class ListVersionsOptions
+{
+ [OptionParameter('d', "descending", HelpText = "Sorts versions descending")]
+ public bool SortDescending { get; set; }
+}
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/TagGroup/Delete/DeleteTagCommand.cs b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/TagGroup/Delete/DeleteTagCommand.cs
index 219f084..01dbc75 100644
--- a/source/GitTool/CreativeCoders.GitTool.Cli.Commands/TagGroup/Delete/DeleteTagCommand.cs
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.Commands/TagGroup/Delete/DeleteTagCommand.cs
@@ -1,6 +1,8 @@
using CreativeCoders.Cli.Core;
+using CreativeCoders.Cli.Hosting.Exceptions;
using CreativeCoders.Core;
using CreativeCoders.Git.Abstractions;
+using CreativeCoders.GitTool.Base;
using CreativeCoders.SysConsole.Core;
using JetBrains.Annotations;
using Spectre.Console;
@@ -19,9 +21,25 @@ public Task ExecuteAsync(DeleteTagOptions options)
{
_ansiConsole.WriteLine($"Deleting tag '{options.TagName}'...");
- _gitRepository.Tags.DeleteTag(options.TagName);
+ var localTagExists =
+ _gitRepository.Tags.Any(x => x.Name.Friendly == options.TagName || x.Name.Canonical == options.TagName);
- _ansiConsole.MarkupLine($"Tag '{options.TagName}' deleted successfully".ToSuccessMarkup());
+ if (!localTagExists && !options.DeleteOnRemote)
+ {
+ throw new CliCommandAbortException($"Tag '{options.TagName}' not found", ReturnCodes.LocalTagNotFound);
+ }
+
+ if (localTagExists)
+ {
+ _gitRepository.Tags.DeleteTag(options.TagName);
+ _ansiConsole.MarkupLine($"Tag '{options.TagName}' deleted successfully".ToSuccessMarkup());
+ }
+ else
+ {
+ _ansiConsole.MarkupLine(
+ $"Skip deleting local tag '{options.TagName}' because it does not exist locally."
+ .ToInfoMarkup());
+ }
if (options.DeleteOnRemote)
{
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/CreativeCoders.GitTool.Cli.GtApp.csproj b/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/CreativeCoders.GitTool.Cli.GtApp.csproj
index 535ceea..6bd35e1 100644
--- a/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/CreativeCoders.GitTool.Cli.GtApp.csproj
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/CreativeCoders.GitTool.Cli.GtApp.csproj
@@ -13,8 +13,8 @@
-
-
+
+
diff --git a/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/Program.cs b/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/Program.cs
index 8f16600..c5640d6 100644
--- a/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/Program.cs
+++ b/source/GitTool/CreativeCoders.GitTool.Cli.GtApp/Program.cs
@@ -25,6 +25,7 @@ internal static async Task Main(string[] args)
var result = await CliHostBuilder.Create()
.ConfigureServices(ConfigureServices)
.PrintFooterText([string.Empty])
+ .UseValidation()
.EnableHelp(HelpCommandKind.CommandOrArgument)
.ScanAssemblies(typeof(ShowConfigCommand).Assembly)
.Build()
diff --git a/source/GitTool/CreativeCoders.GitTool.GitHub/CreativeCoders.GitTool.GitHub.csproj b/source/GitTool/CreativeCoders.GitTool.GitHub/CreativeCoders.GitTool.GitHub.csproj
index f078473..579ed7a 100644
--- a/source/GitTool/CreativeCoders.GitTool.GitHub/CreativeCoders.GitTool.GitHub.csproj
+++ b/source/GitTool/CreativeCoders.GitTool.GitHub/CreativeCoders.GitTool.GitHub.csproj
@@ -5,9 +5,9 @@
-
-
-
+
+
+
diff --git a/source/GitTool/CreativeCoders.GitTool.GitLab/CreativeCoders.GitTool.GitLab.csproj b/source/GitTool/CreativeCoders.GitTool.GitLab/CreativeCoders.GitTool.GitLab.csproj
index e51450e..0332cb7 100644
--- a/source/GitTool/CreativeCoders.GitTool.GitLab/CreativeCoders.GitTool.GitLab.csproj
+++ b/source/GitTool/CreativeCoders.GitTool.GitLab/CreativeCoders.GitTool.GitLab.csproj
@@ -6,7 +6,7 @@
-
+
diff --git a/tests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests.csproj b/tests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests.csproj
index 9c75280..546ee84 100644
--- a/tests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests.csproj
+++ b/tests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests/CreativeCoders.Git.Auth.CredentialManagerCore.UnitTests.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionBuilderTests.cs b/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionBuilderTests.cs
new file mode 100644
index 0000000..9fa7f03
--- /dev/null
+++ b/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionBuilderTests.cs
@@ -0,0 +1,247 @@
+using System.Diagnostics.CodeAnalysis;
+using AwesomeAssertions;
+using CreativeCoders.GitTool.Base.Versioning;
+using Xunit;
+
+namespace CreativeCoders.GitTool.Tests.Base.Versioning;
+
+public class VersionBuilderTests
+{
+ [Theory]
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("1.2", "1.2.0")]
+ [InlineData("1", "1.0.0")]
+ public void Build_ValidVersion_ReturnsCorrectVersionString(string version, string expected)
+ {
+ // Arrange
+ var builder = new VersionBuilder(version);
+
+ // Act
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData(" 1.2.3 ", VersionFormatKind.Loose, "1.2.3")]
+ [InlineData("1 . 2 . 3", VersionFormatKind.Loose, "1.2.3")]
+ public void Build_VersionWithWhitespaceInLooseMode_ReturnsCleanedVersion(string version,
+ VersionFormatKind formatKind, string expected)
+ {
+ // Arrange
+ var builder = new VersionBuilder(version, formatKind);
+
+ // Act
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData(" 1.2.3")]
+ [InlineData("1.2.3 ")]
+ [InlineData("1. 2.3")]
+ [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue")]
+ public void Constructor_VersionWithWhitespaceInStrictMode_ThrowsVersionFormatException(string version)
+ {
+ // Act
+ Action act = () => _ = new VersionBuilder(version, VersionFormatKind.Strict);
+
+ // Assert
+ act.Should().Throw()
+ .And.Version.Should().Be(version);
+ }
+
+ [Theory]
+ [InlineData("1.2.3.4")]
+ [InlineData("a.b.c")]
+ [InlineData("1.2.c")]
+ public void Constructor_InvalidVersionFormat_ThrowsVersionFormatException(string version)
+ {
+ // Act
+ Action act = () => _ = new VersionBuilder(version);
+
+ // Assert
+ act.Should().Throw()
+ .And.Version.Should().Be(version);
+ }
+
+ [Fact]
+ public void IncrementPatch_DefaultIncrement_IncrementsByOne()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementPatch();
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("1.2.4");
+ }
+
+ [Fact]
+ public void IncrementPatch_CustomIncrement_IncrementsByValue()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementPatch(5);
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("1.2.8");
+ }
+
+ [Fact]
+ public void IncrementMinor_DefaultIncrement_IncrementsByOne()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementMinor();
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("1.3.0");
+ }
+
+ [Fact]
+ public void IncrementMinor_WithOutPatchReset_IncrementsByOne()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementMinor(1, false);
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("1.3.3");
+ }
+
+ [Fact]
+ public void IncrementMinor_CustomIncrement_IncrementsByValue()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementMinor(10);
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("1.12.0");
+ }
+
+ [Fact]
+ public void IncrementMajor_DefaultIncrement_IncrementsByOne()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementMajor();
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("2.0.0");
+ }
+
+ [Fact]
+ public void IncrementMajor_WithOutResetMinorAndPatch_MinorAndPatchAreNotResetToZero()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementMajor(1, false);
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("2.2.3");
+ }
+
+ [Fact]
+ public void IncrementMajor_CustomIncrement_IncrementsByValue()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ builder.IncrementMajor(2);
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("3.0.0");
+ }
+
+ [Fact]
+ public void MultipleIncrements_WithOutResetLowerVersionParts_ReturnsCorrectFinalVersion()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.1.1");
+
+ // Act
+ builder.IncrementMajor(1, false);
+ builder.IncrementMinor(2, false);
+ builder.IncrementPatch(3);
+ var result = builder.Build();
+
+ // Assert
+ result.Should().Be("2.3.4");
+ }
+
+ [Fact]
+ public void MajorMinorPatch_Get_ReturnCorrectVersionParts()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ var major = builder.Major;
+ var minor = builder.Minor;
+ var patch = builder.Patch;
+
+ // Assert
+ major.Should().Be(1);
+ minor.Should().Be(2);
+ patch.Should().Be(3);
+ }
+
+ [Fact]
+ public void GetVersionPart_Get_ReturnCorrectVersionParts()
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ var major = builder.GetVersionPart(VersionBuilder.MajorPartIndex);
+ var minor = builder.GetVersionPart(VersionBuilder.MinorPartIndex);
+ var patch = builder.GetVersionPart(VersionBuilder.PatchPartIndex);
+
+ // Assert
+ major.Should().Be(1);
+ minor.Should().Be(2);
+ patch.Should().Be(3);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(3)]
+ public void GetVersionPart_InvalidIndex_ThrowsArgumentOutOfRangeException(int partIndex)
+ {
+ // Arrange
+ var builder = new VersionBuilder("1.2.3");
+
+ // Act
+ Action act = () => _ = builder.GetVersionPart(partIndex);
+
+ // Assert
+ act.Should().Throw();
+ }
+}
diff --git a/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionComparerTests.cs b/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionComparerTests.cs
new file mode 100644
index 0000000..2b03ec7
--- /dev/null
+++ b/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionComparerTests.cs
@@ -0,0 +1,78 @@
+using CreativeCoders.GitTool.Base.Versioning;
+using AwesomeAssertions;
+using Xunit;
+
+namespace CreativeCoders.GitTool.Tests.Base.Versioning;
+
+public class VersionComparerTests
+{
+ [Theory]
+ [InlineData("1.0.0", "1.0.0", 0)]
+ [InlineData("2.0.0", "1.0.0", 1)]
+ [InlineData("1.0.0", "2.0.0", -1)]
+ [InlineData("1.1.0", "1.0.0", 1)]
+ [InlineData("1.0.0", "1.1.0", -1)]
+ [InlineData("1.0.1", "1.0.0", 1)]
+ [InlineData("1.0.0", "1.0.1", -1)]
+ [InlineData("1.1", "1.1.0", 0)]
+ [InlineData("1", "1.0.0", 0)]
+ [InlineData("1.2", "1.1", 1)]
+ [InlineData("1.1", "1.2", -1)]
+ [InlineData("1.10", "1.2", 1)]
+ public void Compare_PositiveCases_ReturnsExpectedResult(string x, string y, int expected)
+ {
+ // Arrange
+ var comparer = new VersionComparer();
+
+ // Act
+ var result = comparer.Compare(x, y);
+
+ // Assert
+ if (expected == 0)
+ {
+ result.Should().Be(0);
+ }
+ else if (expected > 0)
+ {
+ result.Should().BeGreaterThan(0);
+ }
+ else
+ {
+ result.Should().BeLessThan(0);
+ }
+ }
+
+ [Theory]
+ [InlineData(null, null, 0)]
+ [InlineData(null, "1.0.0", -1)]
+ [InlineData("1.0.0", null, 1)]
+ public void Compare_NullValues_ReturnsExpectedResult(string? x, string? y, int expected)
+ {
+ // Arrange
+ var comparer = new VersionComparer();
+
+ // Act
+ var result = comparer.Compare(x, y);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("invalid")]
+ [InlineData("1.0.a")]
+ [InlineData("1.0.0.0")]
+ public void Compare_InvalidVersionFormat_ThrowsVersionFormatException(string invalidVersion)
+ {
+ // Arrange
+ var comparer = new VersionComparer();
+
+ // Act
+ var actX = () => comparer.Compare(invalidVersion, "1.0.0");
+ var actY = () => comparer.Compare("1.0.0", invalidVersion);
+
+ // Assert
+ actX.Should().Throw();
+ actY.Should().Throw();
+ }
+}
diff --git a/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionUtilsTests.cs b/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionUtilsTests.cs
new file mode 100644
index 0000000..525a1af
--- /dev/null
+++ b/tests/CreativeCoders.GitTool.Tests/Base/Versioning/VersionUtilsTests.cs
@@ -0,0 +1,64 @@
+using AwesomeAssertions;
+using CreativeCoders.GitTool.Base.Versioning;
+using Xunit;
+
+namespace CreativeCoders.GitTool.Tests.Base.Versioning;
+
+public class VersionUtilsTests
+{
+ [Theory]
+ [InlineData("1.2.3", true, "1.2.3")]
+ [InlineData("v1.2.3", true, "1.2.3")]
+ [InlineData("version1.0", true, "1.0")]
+ [InlineData("1.2", true, "1.2")]
+ [InlineData("V1.2", true, "1.2")]
+ [InlineData("VERSION1.2", true, "1.2")]
+ [InlineData("1.2.3", false, "1.2.3")]
+ public void IsValidVersion_ValidVersions_ReturnsTrueAndNormalizedVersion(string version, bool ignorePrefix,
+ string expectedNormalized)
+ {
+ // Act
+ var result = VersionUtils.IsValidVersion(version, out var normalizedVersion, ignorePrefix);
+
+ // Assert
+ result.Should().BeTrue();
+ normalizedVersion.Should().Be(expectedNormalized);
+ }
+
+ [Theory]
+ [InlineData("1.a.3", true)]
+ [InlineData("v1.2.x", true)]
+ [InlineData("1..2", true)]
+ [InlineData("", true)]
+ [InlineData(" ", true)]
+ [InlineData("v1.2.3", false)]
+ [InlineData("version1.0", false)]
+ public void IsValidVersion_InvalidVersions_ReturnsFalseAndEmptyNormalizedVersion(string version, bool ignorePrefix)
+ {
+ // Act
+ var result = VersionUtils.IsValidVersion(version, out var normalizedVersion, ignorePrefix);
+
+ // Assert
+ result.Should().BeFalse();
+ normalizedVersion.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData("v1.2.3", "1.2.3")]
+ [InlineData("version1.0", "1.0")]
+ [InlineData("V2.0", "2.0")]
+ [InlineData("VERSION3.1", "3.1")]
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("v", "")]
+ [InlineData("version", "")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ public void RemoveLeadingVersionPrefix_VariousInputs_ReturnsExpectedResult(string version, string expected)
+ {
+ // Act
+ var result = VersionUtils.RemoveLeadingVersionPrefix(version);
+
+ // Assert
+ result.Should().Be(expected);
+ }
+}
diff --git a/tests/CreativeCoders.GitTool.Tests/CreativeCoders.GitTool.Tests.csproj b/tests/CreativeCoders.GitTool.Tests/CreativeCoders.GitTool.Tests.csproj
new file mode 100644
index 0000000..f81dccf
--- /dev/null
+++ b/tests/CreativeCoders.GitTool.Tests/CreativeCoders.GitTool.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+