From 921aa6d1a0572c5f7693fdf3ca7969b318d9e6a3 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Wed, 14 Oct 2026 10:38:00 +0000 Subject: [PATCH] Take only v-prefixed semantic version tags as releases [patch] GetTagsAsync returned every tag, and the version calculator and changelog took the first as the last release. A wip-* tag sorts ahead of every v* tag, and an ios-v* tag is all a shallow tag-triggered checkout sees, so a stray tag moved the version backwards or turned it into the tag's name, exit 0. GetTagsAsync now keeps only tags matching v.. with an optional prerelease suffix. IsCommitTaggedAsync applies the same rule, so an ios-v* or wip-* tag on the commit no longer suppresses its release. Fixes #217 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PDfKv3rGKy33VQUkrTckdA --- KtsuBuild.Tests/Git/GitServiceTests.cs | 116 +++++++++++++++++++++++++ KtsuBuild/Git/GitService.cs | 38 +++++++- 2 files changed, 152 insertions(+), 2 deletions(-) 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; } ///