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 + + + + +