Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions KtsuBuild.Tests/Git/GitServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@
Assert.AreEqual(0, tags.Count);
}

[TestMethod]
public async Task GetTagsAsync_NonVersionTags_AreDropped()
{
_processRunner.RunAsync("git", GitService.ListTagsArguments, Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<string> tags = await _service.GetTagsAsync("/repo").ConfigureAwait(false);

Check warning on line 74 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJO&open=AaE6BVD9jVjprVBzKIJO&pullRequest=248

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<string>(), Arg.Any<CancellationToken>())
.Returns(TestHelpers.SuccessResult("ios-v1.2.0\n"));

IReadOnlyList<string> tags = await _service.GetTagsAsync("/repo").ConfigureAwait(false);

Check warning on line 85 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJP&open=AaE6BVD9jVjprVBzKIJP&pullRequest=248

Assert.AreEqual(0, tags.Count);

Check warning on line 87 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJQ&open=AaE6BVD9jVjprVBzKIJQ&pullRequest=248
}

[TestMethod]
public async Task GetTagsAsync_PassesEveryVersionSortSuffixWithoutWritingConfig()
{
Expand Down Expand Up @@ -116,6 +138,79 @@
}
}

// 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<string> tags = await service.GetTagsAsync(repo).ConfigureAwait(false);

Check warning on line 163 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJR&open=AaE6BVD9jVjprVBzKIJR&pullRequest=248
Assert.AreEqual("v1.4.0,v1.3.0", string.Join(",", tags));

string head = await service.GetCurrentCommitHashAsync(repo).ConfigureAwait(false);

Check warning on line 166 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJS&open=AaE6BVD9jVjprVBzKIJS&pullRequest=248
VersionInfo version = await new VersionCalculator(service, new MockBuildLogger()).GetVersionInfoAsync(repo, head).ConfigureAwait(false);

Check warning on line 167 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJT&open=AaE6BVD9jVjprVBzKIJT&pullRequest=248
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);

Check warning on line 190 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJU&open=AaE6BVD9jVjprVBzKIJU&pullRequest=248
VersionInfo version = await new VersionCalculator(service, new MockBuildLogger()).GetVersionInfoAsync(repo, head).ConfigureAwait(false);

Check warning on line 191 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJV&open=AaE6BVD9jVjprVBzKIJV&pullRequest=248

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

Check warning on line 195 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJW&open=AaE6BVD9jVjprVBzKIJW&pullRequest=248
}
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);
Expand Down Expand Up @@ -378,6 +473,27 @@
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<string>(), Arg.Any<CancellationToken>())
.Returns(TestHelpers.SuccessResult(showRef));

Assert.IsFalse(await _service.IsCommitTaggedAsync("/repo", "abc123").ConfigureAwait(false));

Check warning on line 485 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJX&open=AaE6BVD9jVjprVBzKIJX&pullRequest=248
}

[TestMethod]
public async Task IsCommitTaggedAsync_AnnotatedVersionTagPeeledToCommit_ReturnsTrue()
{
_processRunner.RunAsync("git", "show-ref --tags -d", Arg.Any<string>(), Arg.Any<CancellationToken>())
.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));

Check warning on line 494 in KtsuBuild.Tests/Git/GitServiceTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_KtsuBuild&issues=AaE6BVD9jVjprVBzKIJY&open=AaE6BVD9jVjprVBzKIJY&pullRequest=248
}

// GetFirstCommitAsync

[TestMethod]
Expand Down
38 changes: 36 additions & 2 deletions KtsuBuild/Git/GitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace KtsuBuild.Git;

using System.Text.RegularExpressions;
using KtsuBuild.Abstractions;
using KtsuBuild.Utilities;
#if !NET10_0_OR_GREATER
Expand All @@ -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));

/// <inheritdoc/>
public async Task<IReadOnlyList<string>> GetTagsAsync(string workingDirectory, CancellationToken cancellationToken = default)
{
Expand All @@ -38,9 +44,21 @@ public async Task<IReadOnlyList<string>> GetTagsAsync(string workingDirectory, C
return [.. result.StandardOutput
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(static s => s.Trim())
.Where(static s => !string.IsNullOrEmpty(s))];
.Where(IsVersionTag)];
}

/// <summary>
/// Determines whether a tag names a release: <c>v</c> and a semantic version, with an optional prerelease suffix.
/// </summary>
/// <remarks>
/// Other tags, such as <c>wip-spike</c> or the iOS channel's <c>ios-v1.2.0</c>, sort ahead of the
/// <c>v*</c> 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.
/// </remarks>
/// <param name="tag">The tag name.</param>
/// <returns>True when the tag is a version tag.</returns>
internal static bool IsVersionTag(string tag) => VersionTag.IsMatch(tag);

/// <inheritdoc/>
public async Task<string> GetCurrentCommitHashAsync(string workingDirectory, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -147,7 +165,23 @@ public async Task<bool> 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 "<sha> refs/tags/<name>", and "<name>^{}" 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;
}

/// <inheritdoc/>
Expand Down
Loading