diff --git a/azure-pipelines/dotnet.yml b/azure-pipelines/dotnet.yml index 4fd00fa80..323b2572a 100644 --- a/azure-pipelines/dotnet.yml +++ b/azure-pipelines/dotnet.yml @@ -23,6 +23,13 @@ steps: displayName: 🛠️ Build nerdbank-gitversioning NPM package workingDirectory: src/nerdbank-gitversioning.npm +- pwsh: | + md $(Build.ArtifactStagingDirectory)\drop + 7z a $(Build.ArtifactStagingDirectory)\drop\nbgv.7z * -r + Write-Host "##vso[artifact.upload containerfolder=drop;artifactname=drop;]$(Build.ArtifactStagingDirectory)\drop" + displayName: Capture .git directory + condition: and(failed(), eq(variables['Agent.OS'], 'Windows_NT')) + - powershell: azure-pipelines/dotnet-test-cloud.ps1 -Configuration $(BuildConfiguration) -Agent $(Agent.JobName) -PublishResults displayName: 🧪 dotnet test condition: and(succeeded(), ${{ parameters.RunTests }}) diff --git a/src/NerdBank.GitVersioning/DisabledGit/DisabledGitContext.cs b/src/NerdBank.GitVersioning/DisabledGit/DisabledGitContext.cs index baf180128..0b9c5ca6e 100644 --- a/src/NerdBank.GitVersioning/DisabledGit/DisabledGitContext.cs +++ b/src/NerdBank.GitVersioning/DisabledGit/DisabledGitContext.cs @@ -26,6 +26,8 @@ public DisabledGitContext(string workingTreePath) public override string? HeadCanonicalName => null; + public override IReadOnlyCollection? HeadTags => null; + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (disabled-git)"; public override void ApplyTag(string name) => throw new NotSupportedException(); diff --git a/src/NerdBank.GitVersioning/GitContext.cs b/src/NerdBank.GitVersioning/GitContext.cs index 22e792f49..e7d30c1ae 100644 --- a/src/NerdBank.GitVersioning/GitContext.cs +++ b/src/NerdBank.GitVersioning/GitContext.cs @@ -118,6 +118,11 @@ public string RepoRelativeProjectDirectory /// public abstract string? HeadCanonicalName { get; } + /// + /// Gets a collection of the tags that reference HEAD. + /// + public abstract IReadOnlyCollection? HeadTags { get; } + /// /// Gets the path to the .git folder. /// diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs index 1aa804c17..00294c7d5 100644 --- a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs @@ -14,6 +14,11 @@ namespace Nerdbank.GitVersioning.LibGit2; [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] public class LibGit2Context : GitContext { + /// + /// Caching field behind property. + /// + private IReadOnlyCollection? headTags; + internal LibGit2Context(string workingTreeDirectory, string dotGitPath, string? committish = null) : base(workingTreeDirectory, dotGitPath) { @@ -51,6 +56,20 @@ internal LibGit2Context(string workingTreeDirectory, string dotGitPath, string? /// public override string HeadCanonicalName => this.Repository.Head.CanonicalName; + /// + public override IReadOnlyCollection? HeadTags + { + get + { + return this.headTags ??= this.Commit is not null + ? this.Repository.Tags + .Where(tag => tag.Target.Sha.Equals(this.Commit.Sha)) + .Select(tag => tag.CanonicalName) + .ToList() + : null; + } + } + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (libgit2)"; /// Initializes a new instance of the class. diff --git a/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs b/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs index aa4cc1072..b21977995 100644 --- a/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs +++ b/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs @@ -15,6 +15,11 @@ namespace Nerdbank.GitVersioning.Managed; [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] public class ManagedGitContext : GitContext { + /// + /// Caching field behind property. + /// + private IReadOnlyCollection? headTags; + internal ManagedGitContext(string workingDirectory, string dotGitPath, string? committish = null) : base(workingDirectory, dotGitPath) { @@ -53,6 +58,12 @@ internal ManagedGitContext(string workingDirectory, string dotGitPath, string? c /// public override string HeadCanonicalName => this.Repository.GetHeadAsReferenceOrSha().ToString() ?? throw new InvalidOperationException("Unable to determine the HEAD position."); + /// + public override IReadOnlyCollection? HeadTags + { + get => this.headTags ??= this.Repository.Lookup("HEAD") is GitObjectId head ? this.Repository.LookupTags(head) : null; + } + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (managed)"; /// Initializes a new instance of the class. diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitAnnotatedTag.cs b/src/NerdBank.GitVersioning/ManagedGit/GitAnnotatedTag.cs new file mode 100644 index 000000000..6ca2ffa89 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitAnnotatedTag.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +using System.Collections; + +namespace Nerdbank.GitVersioning.ManagedGit; + +/// +/// Represents a Git annotated tag, as stored in the Git object database. +/// +public struct GitAnnotatedTag : IEquatable +{ + /// + /// Gets or sets the of object this tag is pointing at. + /// + public GitObjectId Object { get; set; } + + /// + /// Gets or sets a which uniquely identifies the . + /// + public GitObjectId Sha { get; set; } + + /// + /// Gets or sets the tag name of this annotated tag. + /// + public string Tag { get; set; } + + /// + /// Gets or sets the type of the object this tag is pointing to, e.g. "commit" or, for nested tags, "tag". + /// + public string Type { get; set; } + + public static bool operator ==(GitAnnotatedTag left, GitAnnotatedTag right) => left.Equals(right); + + public static bool operator !=(GitAnnotatedTag left, GitAnnotatedTag right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is GitAnnotatedTag tag ? this.Equals(tag) : false; + + /// + public bool Equals(GitAnnotatedTag other) => this.Sha.Equals(other.Sha); + + /// + public override int GetHashCode() => this.Sha.GetHashCode(); + + /// + public override string ToString() + { + return $"Git Tag: {this.Tag} with id {this.Sha}"; + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitAnnotatedTagReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitAnnotatedTagReader.cs new file mode 100644 index 000000000..654bfee0a --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitAnnotatedTagReader.cs @@ -0,0 +1,133 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +using System.Buffers; +using System.Diagnostics; +using System.Xml.Linq; +using LibGit2Sharp; +using Validation; + +namespace Nerdbank.GitVersioning.ManagedGit; + +/// +/// Reads a object. +/// +public static class GitAnnotatedTagReader +{ + private const int ObjectLineLength = 48; + + private static readonly byte[] ObjectStart = GitRepository.Encoding.GetBytes("object "); + private static readonly byte[] TypeStart = GitRepository.Encoding.GetBytes("type "); + private static readonly byte[] TagStart = GitRepository.Encoding.GetBytes("tag "); + + /// + /// Reads a object from a . + /// + /// + /// A which contains the in its text representation. + /// + /// + /// The of the commit. + /// + /// + /// The . + /// + public static GitAnnotatedTag Read(Stream stream, GitObjectId sha) + { + Requires.NotNull(stream, nameof(stream)); + + byte[] buffer = ArrayPool.Shared.Rent(checked((int)stream.Length)); + + try + { + Span span = buffer.AsSpan(0, (int)stream.Length); + stream.ReadAll(span); + + return Read(span, sha); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Reads a object from a . + /// + /// + /// A which contains the in its text representation. + /// + /// + /// The of the annotated tag. + /// + /// + /// The . + /// + public static GitAnnotatedTag Read(ReadOnlySpan tag, GitObjectId sha) + { + ReadOnlySpan buffer = tag; + + GitObjectId obj = ReadObject(buffer.Slice(0, ObjectLineLength)); + buffer = buffer.Slice(ObjectLineLength); + + (string type, int typeLen) = ReadType(buffer); + buffer = buffer.Slice(typeLen); + + (string tagName, _) = ReadTag(buffer); + + return new GitAnnotatedTag + { + Sha = sha, + Object = obj, + Type = type, + Tag = tagName, + }; + } + + private static GitObjectId ReadObject(ReadOnlySpan line) + { + // Format: object d8329fc1cc938780ffdd9f94e0d364e0ea74f579\n + // 48 bytes: + // object: 6 bytes + // space: 1 byte + // hash: 40 bytes + // \n: 1 byte + Debug.Assert(line.Slice(0, ObjectStart.Length).SequenceEqual(ObjectStart)); + Debug.Assert(line[ObjectLineLength - 1] == (byte)'\n'); + + return GitObjectId.ParseHex(line.Slice(ObjectStart.Length, 40)); + } + + private static (string Content, int BytesRead) ReadPrefixedString(ReadOnlySpan remaining, byte[] prefix) + { + Debug.Assert(remaining.Slice(0, prefix.Length).SequenceEqual(prefix)); + + int lineEnd = remaining.IndexOf((byte)'\n'); + ReadOnlySpan type = remaining.Slice(prefix.Length, lineEnd - prefix.Length); + return (GitRepository.GetString(type), lineEnd + 1); + } + + private static (string Content, int BytesRead) ReadType(ReadOnlySpan remaining) + { + // Format: type commit\n + // bytes: + // type: 4 bytes + // space: 1 byte + // : bytes + // \n: 1 byte + return ReadPrefixedString(remaining, TypeStart); + } + + private static (string Content, int BytesRead) ReadTag(ReadOnlySpan remaining) + { + // Format: tag someAnnotatedTag\n + // bytes: + // tag: 3 bytes + // space: 1 byte + // : bytes + // \n: 1 byte + return ReadPrefixedString(remaining, TagStart); + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs index e4629e2ad..a59ced3b9 100644 --- a/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs +++ b/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs @@ -123,8 +123,8 @@ public static GitCommit Read(ReadOnlySpan commit, GitObjectId sha, bool re private static GitObjectId ReadTree(ReadOnlySpan line) { // Format: tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579\n - // 47 bytes: - // tree: 5 bytes + // 46 bytes: + // tree: 4 bytes // space: 1 byte // hash: 40 bytes // \n: 1 byte diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs b/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs index 9dc03ef0b..f8aeb63d2 100644 --- a/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs +++ b/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs @@ -67,17 +67,17 @@ public static GitObjectId Parse(ReadOnlySpan value) } /// - /// Parses a which contains the hexadecimal representation of a - /// . + /// Parses a of which contains the + /// hexadecimal representation of a . /// /// - /// A which contains the hexadecimal representation of the - /// . + /// A of which contains the + /// hexadecimal representation of the . /// /// /// A . /// - public static GitObjectId Parse(string value) + public static GitObjectId Parse(ReadOnlySpan value) { Debug.Assert(value.Length == 40); @@ -92,6 +92,23 @@ public static GitObjectId Parse(string value) bytes[i >> 1] = (byte)(c1 + c2); } + return objectId; + } + + /// + /// Parses a which contains the hexadecimal representation of a + /// . + /// + /// + /// A which contains the hexadecimal representation of the + /// . + /// + /// + /// A . + /// + public static GitObjectId Parse(string value) + { + GitObjectId objectId = Parse(value.AsSpan()); objectId.sha = value.ToLower(); return objectId; } @@ -109,7 +126,10 @@ public static GitObjectId Parse(string value) /// public static GitObjectId ParseHex(ReadOnlySpan value) { - Debug.Assert(value.Length == 40); + if (value.Length != 40) + { + throw new ArgumentException($"Length should be exactly 40, but was {value.Length}.", nameof(value)); + } var objectId = default(GitObjectId); Span bytes = objectId.Value; diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs index 6a6592c42..d8b94526b 100644 --- a/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs @@ -3,6 +3,7 @@ #nullable enable +using System.Diagnostics.CodeAnalysis; using System.IO.MemoryMappedFiles; using System.Text; @@ -141,7 +142,7 @@ public GitPack(GetObjectFromRepositoryDelegate getObjectFromRepositoryDelegate, /// /// if the object was found; otherwise, . /// - public bool TryGetObject(GitObjectId objectId, string objectType, out Stream? value) + public bool TryGetObject(GitObjectId objectId, string objectType, [NotNullWhen(true)] out Stream? value) { long? offset = this.GetOffset(objectId); @@ -152,8 +153,18 @@ public bool TryGetObject(GitObjectId objectId, string objectType, out Stream? va } else { - value = this.GetObject(offset.Value, objectType); - return true; + // This try-catch should probably be replaced by a non-throwing GetObject implementation. + // This is in turn dependend on a proper GitPackReader.TryGetObject implementation. + try + { + value = this.GetObject(offset.Value, objectType); + return true; + } + catch (GitException gexc) when (gexc.ErrorCode == GitException.ErrorCodes.ObjectNotFound) + { + value = null; + return false; + } } } @@ -204,6 +215,10 @@ public Stream GetObject(long offset, string objectType) packObjectType = GitPackObjectType.OBJ_BLOB; break; + case "tag": + packObjectType = GitPackObjectType.OBJ_TAG; + break; + default: throw new GitException($"The object type '{objectType}' is not supported by the {nameof(GitPack)} class."); } diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs b/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs index 34961cf32..e4c8b0b7f 100644 --- a/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs +++ b/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Copyright (c) .NET Foundation and Contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. #nullable enable @@ -377,38 +377,29 @@ public GitCommit GetCommit(GitObjectId sha, bool readAuthor = false) } // Match in packed-refs file. - string packedRefPath = Path.Combine(this.CommonDirectory, "packed-refs"); - if (File.Exists(packedRefPath)) + foreach ((string line, string? _) in this.EnumeratePackedRefsWithPeelLines(out var _)) { - using StreamReader? refReader = File.OpenText(packedRefPath); - string? line; - while ((line = refReader.ReadLine()) is object) + var refName = line.Substring(41); + GitObjectId GetObjId() => GitObjectId.Parse(line.AsSpan().Slice(0, 40)); + + if (string.Equals(refName, objectish, StringComparison.Ordinal)) { - if (line.StartsWith("#", StringComparison.Ordinal)) + return GetObjId(); + } + else if (!objectish.StartsWith("refs/", StringComparison.Ordinal)) + { + // Not a canonical ref, so try heads and tags + if (string.Equals(refName, "refs/heads/" + objectish, StringComparison.Ordinal)) { - continue; + return GetObjId(); } - - string refName = line.Substring(41); - if (string.Equals(refName, objectish, StringComparison.Ordinal)) + else if (string.Equals(refName, "refs/tags/" + objectish, StringComparison.Ordinal)) { - return GitObjectId.Parse(line.Substring(0, 40)); + return GetObjId(); } - else if (!objectish.StartsWith("refs/", StringComparison.Ordinal)) + else if (string.Equals(refName, "refs/remotes/" + objectish, StringComparison.Ordinal)) { - // Not a canonical ref, so try heads and tags - if (string.Equals(refName, "refs/heads/" + objectish, StringComparison.Ordinal)) - { - return GitObjectId.Parse(line.Substring(0, 40)); - } - else if (string.Equals(refName, "refs/tags/" + objectish, StringComparison.Ordinal)) - { - return GitObjectId.Parse(line.Substring(0, 40)); - } - else if (string.Equals(refName, "refs/remotes/" + objectish, StringComparison.Ordinal)) - { - return GitObjectId.Parse(line.Substring(0, 40)); - } + return GetObjId(); } } } @@ -582,7 +573,7 @@ public GitObjectId GetTreeEntry(GitObjectId treeId, ReadOnlySpan nodeName) /// if the object could be found; otherwise, /// . /// - public bool TryGetObjectBySha(GitObjectId sha, string objectType, out Stream? value) + public bool TryGetObjectBySha(GitObjectId sha, string objectType, [NotNullWhen(true)] out Stream? value) { #if DEBUG if (!this.histogram.TryAdd(sha, 1)) @@ -648,6 +639,62 @@ public string GetCacheStatistics() return builder.ToString(); } + /// + /// Returns a list of canonical names of tags that point to the given Git object id. + /// + /// The git object id to get the corresponding tags for. + /// A list of canonical names of tags. + public List LookupTags(GitObjectId objectId) + { + var tags = new List(); + + void HandleCandidate(GitObjectId pointsAt, string tagName, bool isPeeled) + { + if (objectId.Equals(pointsAt)) + { + tags.Add(tagName); + } + else if (!isPeeled && this.TryGetObjectBySha(pointsAt, "tag", out Stream? tagContent)) + { + GitAnnotatedTag tag = GitAnnotatedTagReader.Read(tagContent, pointsAt); + if ("commit".Equals(tag.Type, StringComparison.Ordinal) && objectId.Equals(tag.Object)) + { + tags.Add($"refs/tags/{tag.Tag}"); + } + } + } + + // Both tag files and packed-refs might either contain lightweight or annotated tags. + // tag files + var tagDir = Path.Combine(this.CommonDirectory, "refs", "tags"); + foreach (var tagFile in Directory.EnumerateFiles(tagDir, "*", SearchOption.AllDirectories)) + { + var tagObjId = GitObjectId.ParseHex(File.ReadAllBytes(tagFile).AsSpan().Slice(0, 40)); + + // \ is not legal in git tag names + var tagName = tagFile.Substring(tagDir.Length + 1).Replace('\\', '/'); + var canonical = $"refs/tags/{tagName}"; + + HandleCandidate(tagObjId, canonical, false); + } + + // packed-refs file + foreach ((string line, string? peelLine) in this.EnumeratePackedRefsWithPeelLines(out var tagsPeeled)) + { + var refName = line.Substring(41); + + // If we remove this check we do find local and remote branch heads too. + if (refName.StartsWith("refs/tags/", StringComparison.Ordinal)) + { + ReadOnlySpan tagSpan = peelLine is null ? line.AsSpan().Slice(0, 40) : peelLine.AsSpan().Slice(1, 40); + var tagObjId = GitObjectId.Parse(tagSpan); + HandleCandidate(tagObjId, refName, tagsPeeled); + } + } + + return tags; + } + /// public override string ToString() { @@ -767,4 +814,112 @@ private ReadOnlyMemory LoadPacks() return packs.AsMemory(0, addCount); } + + private IEnumerable EnumerateLines(string filePath) + { + using StreamReader sr = File.OpenText(filePath); + string? line; + while ((line = sr.ReadLine()) is not null) + { + yield return line; + } + } + + /// + /// Enumerate the lines in the packed-refs file. Skips comment lines. + /// + private IEnumerable EnumeratePackedRefsRaw(out bool tagsPeeled) + { + tagsPeeled = false; + string packedRefPath = Path.Combine(this.CommonDirectory, "packed-refs"); + if (!File.Exists(packedRefPath)) + { + return Enumerable.Empty(); + } + + // We use the rather simple EnumerateLines iterator here because this way + // the disposable StreamReader can survive when this method already returned and + // Enumerate() runs. + IEnumerator lines = this.EnumerateLines(packedRefPath).GetEnumerator(); + if (!lines.MoveNext()) + { + return Enumerable.Empty(); + } + + // see https://github.com/git/git/blob/d9d677b2d8cc5f70499db04e633ba7a400f64cbf/refs/packed-backend.c#L618 + const string fileHeaderPrefix = "# pack-refs with:"; + string firstLine = lines.Current; + if (firstLine.StartsWith(fileHeaderPrefix)) + { + // could contain "peeled" or "fully-peeled" or (typically) both. + // The meaning of any of these is equivalent for our use case. +#if NETFRAMEWORK + tagsPeeled = firstLine.IndexOf("peeled", StringComparison.Ordinal) >= 0; +#else + tagsPeeled = firstLine.Contains("peeled", StringComparison.Ordinal); +#endif + } + + IEnumerable Enumerate() + { + do + { + // We process the first line here again and continue because it starts with #. + // We could add a MoveNext() above if the header prefix was found, but we'd need + // to handle the case that it returned false then. + var line = lines.Current; + if (line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + yield return line; + } + while (lines.MoveNext()); + } + + return Enumerate(); + } + + /// + /// Enumerate the lines in the packed-refs file. If a line has a corresponding peel + /// line, they are returned together. + /// + private IEnumerable<(string Record, string? PeelLine)> EnumeratePackedRefsWithPeelLines(out bool tagsPeeled) + { + IEnumerable rawEnum = this.EnumeratePackedRefsRaw(out tagsPeeled); + return Enumerate(); + + IEnumerable<(string Record, string? PeelLine)> Enumerate() + { + string? recordLine = null; + foreach (var line in rawEnum) + { + if (line[0] == '^') + { + if (recordLine is null) + { + throw new GitException("packed-refs format is broken. Found a peel line without a preceeding record it belongs to."); + } + + yield return (recordLine, line); + recordLine = null; + } + else + { + if (recordLine is not null) + { + yield return (recordLine, null); + } + + recordLine = line; + } + } + + if (recordLine is not null) + { + yield return (recordLine, null); + } + } + } } diff --git a/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs b/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs index b1e15b472..ab169d89d 100644 --- a/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs +++ b/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs @@ -33,6 +33,9 @@ public NoGitContext(string workingTreePath) /// public override string? HeadCanonicalName => null; + /// + public override IReadOnlyCollection? HeadTags => null; + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (no-git)"; /// diff --git a/src/NerdBank.GitVersioning/VersionOracle.cs b/src/NerdBank.GitVersioning/VersionOracle.cs index c3d2160ae..e00d1937b 100644 --- a/src/NerdBank.GitVersioning/VersionOracle.cs +++ b/src/NerdBank.GitVersioning/VersionOracle.cs @@ -61,6 +61,7 @@ public VersionOracle(GitContext context, ICloudBuild? cloudBuild = null, int? ov } this.BuildingRef = cloudBuild?.BuildingTag ?? cloudBuild?.BuildingBranch ?? context.HeadCanonicalName; + try { this.VersionHeight = context.CalculateVersionHeight(this.CommittedVersion, this.WorkingVersion); @@ -102,10 +103,19 @@ public VersionOracle(GitContext context, ICloudBuild? cloudBuild = null, int? ov : this.GitCommitId!.Substring(0, gitCommitIdShortFixedLength); } - if (!string.IsNullOrEmpty(this.BuildingRef) && this.VersionOptions?.PublicReleaseRefSpec?.Count > 0) + if (this.VersionOptions?.PublicReleaseRefSpec?.Count > 0) { - this.PublicRelease = this.VersionOptions.PublicReleaseRefSpec.Any( - expr => Regex.IsMatch(this.BuildingRef, expr)); + if (this.BuildingRef is not null) + { + this.PublicRelease = this.VersionOptions.PublicReleaseRefSpec.Any( + expr => Regex.IsMatch(this.BuildingRef, expr)); + } + + if (!this.PublicRelease && this.VersionOptions.PublicReleaseRefSpec.Any(expr => expr.StartsWith("^refs/tags/", StringComparison.Ordinal)) && this.Tags is not null) + { + this.PublicRelease = this.VersionOptions.PublicReleaseRefSpec.Any( + expr => this.Tags.Any(cand => Regex.IsMatch(cand, expr))); + } } } @@ -272,8 +282,17 @@ public IEnumerable BuildMetadataWithCommitId /// /// Gets or sets the ref (branch or tag) being built. /// + /// + /// Just contains a tag if it is known that explicitly this tag is built, e.g. in a cloud build context. + /// public string? BuildingRef { get; protected set; } + /// + /// Gets a collection of the tags that reference HEAD. + /// + [Ignore] + public IReadOnlyCollection? Tags => this.context.HeadTags; + /// /// Gets or sets the version for this project, with up to 4 components. /// diff --git a/test/Nerdbank.GitVersioning.Tests/VersionOracleTests.cs b/test/Nerdbank.GitVersioning.Tests/VersionOracleTests.cs index aae31b8df..946fc5227 100644 --- a/test/Nerdbank.GitVersioning.Tests/VersionOracleTests.cs +++ b/test/Nerdbank.GitVersioning.Tests/VersionOracleTests.cs @@ -1037,6 +1037,50 @@ public void GetVersionHeight_ProjectDirectoryIsMoved() Assert.Equal(1, this.GetVersionHeight("new-project-dir")); } + [Fact] + public void Tags() + { + this.WriteVersionFile(new VersionOptions { Version = SemanticVersion.Parse("1.2"), GitCommitIdShortAutoMinimum = 4 }); + this.InitializeSourceControl(); + this.AddCommits(1); + VersionOracle oracle = new(this.Context); + + // Assert that we don't see any tags. + Assert.Empty(oracle.Tags); + + // Create a tag. + this.LibGit2Repository.ApplyTag("mytag"); + + // Refresh our context before asking again. + this.Context = this.CreateGitContext(this.RepoPath); + VersionOracle oracle2 = new(this.Context); + + // Assert that we see the tag. + Assert.Equal("refs/tags/mytag", Assert.Single(oracle2.Tags)); + } + + [Fact] + public void Tags_Annotated() + { + this.WriteVersionFile(new VersionOptions { Version = SemanticVersion.Parse("1.2"), GitCommitIdShortAutoMinimum = 4 }); + this.InitializeSourceControl(); + this.AddCommits(1); + VersionOracle oracle = new(this.Context); + + // Assert that we don't see any tags. + Assert.Empty(oracle.Tags); + + // Create a tag. + this.LibGit2Repository.ApplyTag("mytag", this.Signer, "my tag"); + + // Refresh our context before asking again. + this.Context = this.CreateGitContext(this.RepoPath); + VersionOracle oracle2 = new(this.Context); + + // Assert that we see the tag. + Assert.Equal("refs/tags/mytag", Assert.Single(oracle2.Tags)); + } + [Fact] public void GitCommitIdShort() {