diff --git a/KtsuBuild.Tests/Git/GitServiceTests.cs b/KtsuBuild.Tests/Git/GitServiceTests.cs index d9f9381..f29c20d 100644 --- a/KtsuBuild.Tests/Git/GitServiceTests.cs +++ b/KtsuBuild.Tests/Git/GitServiceTests.cs @@ -65,6 +65,28 @@ public async Task GetTagsAsync_FailedProcess_ReturnsEmptyList() Assert.AreEqual(0, tags.Count); } + [TestMethod] + public async Task GetTagsAsync_NonVersionTags_AreDropped() + { + _processRunner.RunAsync("git", GitService.ListTagsArguments, Arg.Any(), Arg.Any()) + .Returns(TestHelpers.SuccessResult("x-marker\nwip-spike\nv1.4.0\nios-v1.2.0\nv1.4.0-rc.1\nv1.3\nv1.3.0\n")); + + IReadOnlyList tags = await _service.GetTagsAsync("/repo").ConfigureAwait(false); + + Assert.AreEqual("v1.4.0,v1.4.0-rc.1,v1.3.0", string.Join(",", tags)); + } + + [TestMethod] + public async Task GetTagsAsync_OnlyNonVersionTags_ReturnsEmptyList() + { + _processRunner.RunAsync("git", GitService.ListTagsArguments, Arg.Any(), Arg.Any()) + .Returns(TestHelpers.SuccessResult("ios-v1.2.0\n")); + + IReadOnlyList tags = await _service.GetTagsAsync("/repo").ConfigureAwait(false); + + Assert.AreEqual(0, tags.Count); + } + [TestMethod] public async Task GetTagsAsync_PassesEveryVersionSortSuffixWithoutWritingConfig() { @@ -116,6 +138,79 @@ public async Task GetTagsAsync_RealRepository_SortsPrereleaseBelowItsRelease(str } } + // Against a real repository: a wip-* tag sorts ahead of every v* tag, and an ios-v* tag on HEAD + // is all a shallow tag-triggered checkout sees, so each was taken as the last release. + + [TestMethod] + public async Task GetVersionInfoAsync_RealRepository_IgnoresNonVersionTags() + { + string repo = Path.Combine(Path.GetTempPath(), $"ktsubuild-tags-{Guid.NewGuid():N}"); + Directory.CreateDirectory(repo); + + try + { + ProcessRunner runner = new(); + await CommitAsync(runner, repo, "initial").ConfigureAwait(false); + await RunGitAsync(runner, repo, "tag", "v1.3.0").ConfigureAwait(false); + await CommitAsync(runner, repo, "Add feature").ConfigureAwait(false); + await RunGitAsync(runner, repo, "tag", "v1.4.0").ConfigureAwait(false); + await CommitAsync(runner, repo, "Spike").ConfigureAwait(false); + await RunGitAsync(runner, repo, "tag", "wip-spike").ConfigureAwait(false); + await RunGitAsync(runner, repo, "tag", "ios-v1.2.0").ConfigureAwait(false); + await CommitAsync(runner, repo, "Fix crash").ConfigureAwait(false); + + GitService service = new(runner, new MockBuildLogger()); + IReadOnlyList tags = await service.GetTagsAsync(repo).ConfigureAwait(false); + Assert.AreEqual("v1.4.0,v1.3.0", string.Join(",", tags)); + + string head = await service.GetCurrentCommitHashAsync(repo).ConfigureAwait(false); + VersionInfo version = await new VersionCalculator(service, new MockBuildLogger()).GetVersionInfoAsync(repo, head).ConfigureAwait(false); + Assert.AreEqual("v1.4.0", version.LastTag); + Assert.AreEqual("1.4.0", version.LastVersion); + } + finally + { + DeleteDirectory(repo); + } + } + + [TestMethod] + public async Task GetVersionInfoAsync_RealRepository_OnlyIosTag_UsesFallback() + { + string repo = Path.Combine(Path.GetTempPath(), $"ktsubuild-tags-{Guid.NewGuid():N}"); + Directory.CreateDirectory(repo); + + try + { + ProcessRunner runner = new(); + await CommitAsync(runner, repo, "initial").ConfigureAwait(false); + await RunGitAsync(runner, repo, "tag", "ios-v1.2.0").ConfigureAwait(false); + + GitService service = new(runner, new MockBuildLogger()); + string head = await service.GetCurrentCommitHashAsync(repo).ConfigureAwait(false); + VersionInfo version = await new VersionCalculator(service, new MockBuildLogger()).GetVersionInfoAsync(repo, head).ConfigureAwait(false); + + Assert.AreEqual("v1.0.0-pre.0", version.LastTag); + Assert.IsFalse(version.Version.Contains("ios", StringComparison.Ordinal), version.Version); + Assert.IsFalse(await service.IsCommitTaggedAsync(repo, head).ConfigureAwait(false)); + } + finally + { + DeleteDirectory(repo); + } + } + + private static Task CommitAsync(ProcessRunner runner, string repo, string message) => + Directory.Exists(Path.Combine(repo, ".git")) + ? RunGitAsync(runner, repo, "-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "--allow-empty", "-m", message) + : InitAndCommitAsync(runner, repo, message); + + private static async Task InitAndCommitAsync(ProcessRunner runner, string repo, string message) + { + await RunGitAsync(runner, repo, "init", "-q").ConfigureAwait(false); + await RunGitAsync(runner, repo, "-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "--allow-empty", "-m", message).ConfigureAwait(false); + } + private static async Task RunGitAsync(ProcessRunner runner, string repo, params string[] arguments) { ProcessResult result = await runner.RunAsync("git", arguments, repo).ConfigureAwait(false); @@ -378,6 +473,27 @@ public async Task IsCommitTaggedAsync_CommitNotInTags_ReturnsFalse() Assert.IsFalse(result); } + [TestMethod] + [DataRow("abc123 refs/tags/ios-v1.2.0\n")] + [DataRow("abc123 refs/tags/wip-spike\n")] + [DataRow("abc123 refs/tags/ios-v1.2.0^{}\ndef456 refs/tags/v1.0.0\n")] + public async Task IsCommitTaggedAsync_OnlyNonVersionTagOnCommit_ReturnsFalse(string showRef) + { + _processRunner.RunAsync("git", "show-ref --tags -d", Arg.Any(), Arg.Any()) + .Returns(TestHelpers.SuccessResult(showRef)); + + Assert.IsFalse(await _service.IsCommitTaggedAsync("/repo", "abc123").ConfigureAwait(false)); + } + + [TestMethod] + public async Task IsCommitTaggedAsync_AnnotatedVersionTagPeeledToCommit_ReturnsTrue() + { + _processRunner.RunAsync("git", "show-ref --tags -d", Arg.Any(), Arg.Any()) + .Returns(TestHelpers.SuccessResult("fff000 refs/tags/v1.0.0\nabc123 refs/tags/v1.0.0^{}\n")); + + Assert.IsTrue(await _service.IsCommitTaggedAsync("/repo", "abc123").ConfigureAwait(false)); + } + // GetFirstCommitAsync [TestMethod] diff --git a/KtsuBuild/Git/GitService.cs b/KtsuBuild/Git/GitService.cs index 2fc20e3..5ad87da 100644 --- a/KtsuBuild/Git/GitService.cs +++ b/KtsuBuild/Git/GitService.cs @@ -2,6 +2,7 @@ namespace KtsuBuild.Git; +using System.Text.RegularExpressions; using KtsuBuild.Abstractions; using KtsuBuild.Utilities; #if !NET10_0_OR_GREATER @@ -24,6 +25,11 @@ public class GitService(IProcessRunner processRunner, IBuildLogger logger) : IGi internal const string ListTagsArguments = "-c versionsort.suffix=-alpha -c versionsort.suffix=-beta -c versionsort.suffix=-rc -c versionsort.suffix=-pre tag --list --sort=-v:refname"; + private static readonly Regex VersionTag = new( + @"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$", + RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(1)); + /// public async Task> GetTagsAsync(string workingDirectory, CancellationToken cancellationToken = default) { @@ -38,9 +44,21 @@ public async Task> GetTagsAsync(string workingDirectory, C return [.. result.StandardOutput .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(static s => s.Trim()) - .Where(static s => !string.IsNullOrEmpty(s))]; + .Where(IsVersionTag)]; } + /// + /// Determines whether a tag names a release: v and a semantic version, with an optional prerelease suffix. + /// + /// + /// Other tags, such as wip-spike or the iOS channel's ios-v1.2.0, sort ahead of the + /// v* tags or stand alone in a shallow checkout. Taking one as the last release moved the version + /// backwards or turned it into the tag's name. + /// + /// The tag name. + /// True when the tag is a version tag. + internal static bool IsVersionTag(string tag) => VersionTag.IsMatch(tag); + /// public async Task GetCurrentCommitHashAsync(string workingDirectory, CancellationToken cancellationToken = default) { @@ -147,7 +165,23 @@ public async Task IsCommitTaggedAsync(string workingDirectory, string comm Ensure.NotNull(workingDirectory); Ensure.NotNull(commitHash); ProcessResult result = await processRunner.RunAsync("git", "show-ref --tags -d", workingDirectory, cancellationToken).ConfigureAwait(false); - return result.StandardOutput.Contains(commitHash); + + // Only a version tag marks a commit as released, so an ios-v* or wip-* tag on it doesn't suppress the release. + // Each line is " refs/tags/", and "^{}" for the commit an annotated tag points at. + return result.StandardOutput + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(static line => line.Trim().Split(' ', 2)) + .Any(fields => fields.Length == 2 + && fields[0].StartsWith(commitHash, StringComparison.OrdinalIgnoreCase) + && IsVersionTag(TagName(fields[1]))); + } + + private static string TagName(string reference) + { + const string Prefix = "refs/tags/"; + const string Peeled = "^{}"; + string name = reference.StartsWith(Prefix, StringComparison.Ordinal) ? reference[Prefix.Length..] : reference; + return name.EndsWith(Peeled, StringComparison.Ordinal) ? name[..^Peeled.Length] : name; } ///