diff --git a/src/GitVersion.Core.Tests/Core/RepositoryStoreCommitLogTests.cs b/src/GitVersion.Core.Tests/Core/RepositoryStoreCommitLogTests.cs new file mode 100644 index 0000000000..5aa150eb73 --- /dev/null +++ b/src/GitVersion.Core.Tests/Core/RepositoryStoreCommitLogTests.cs @@ -0,0 +1,191 @@ +using GitVersion.Configuration; +using GitVersion.Git; +using GitVersion.Testing.Extensions; + +namespace GitVersion.Tests; + +/// +/// The commit log with excluded commits is derived from a single cached revision walk instead of asking git for +/// one walk per base version source. These tests pin that optimisation to the behaviour of the walks it replaces. +/// +[TestFixture] +public class RepositoryStoreCommitLogTests : TestBase +{ + private static readonly IReadOnlySet NothingExcluded = new HashSet(); + + [Test] + public void DerivedCommitLogMatchesRevisionWalkForEveryBaseVersionSource() + { + using var fixture = CreateBranchedAndMergedRepository(); + var repository = fixture.Repository.ToGitRepository(); + var sut = new RepositoryStore(NullLogger.Instance, repository); + var ignore = new IgnoreConfiguration(); + + var head = repository.Head.Tip.ShouldNotBeNull(); + + foreach (var baseVersionSource in AllCommits(repository)) + { + var actual = sut.GetCommitLog(baseVersionSource, head, ignore, NothingExcluded).Select(element => element.Sha); + var expected = RevisionWalk(repository, baseVersionSource, head).Select(element => element.Sha); + + actual.ShouldBe(expected, $"commit log for base version source '{baseVersionSource.Sha}'"); + } + } + + [Test] + public void DerivedCommitLogMatchesRevisionWalkWhenBaseVersionSourceIsUnreachableFromHead() + { + using var fixture = CreateBranchedAndMergedRepository(); + + // 'feature/unmerged' is never merged back, so its tip is not reachable from the head of 'main' while + // some of its ancestors still are. + fixture.Checkout("main"); + fixture.BranchTo("feature/unmerged"); + fixture.MakeACommit("unmerged one"); + fixture.MakeACommit("unmerged two"); + var repository = fixture.Repository.ToGitRepository(); + var unmergedTip = repository.FindBranch("feature/unmerged").ShouldNotBeNull().Tip.ShouldNotBeNull(); + + fixture.Checkout("main"); + fixture.MakeACommit("main after branching off"); + + repository = fixture.Repository.ToGitRepository(); + var sut = new RepositoryStore(NullLogger.Instance, repository); + var ignore = new IgnoreConfiguration(); + var head = repository.Head.Tip.ShouldNotBeNull(); + + var actual = sut.GetCommitLog(unmergedTip, head, ignore, NothingExcluded).Select(element => element.Sha); + var expected = RevisionWalk(repository, unmergedTip, head).Select(element => element.Sha); + + actual.ShouldBe(expected); + } + + [Test] + public void DerivedCommitLogExcludesTheAncestorsOfTheGivenShas() + { + using var fixture = CreateBranchedAndMergedRepository(); + var repository = fixture.Repository.ToGitRepository(); + var sut = new RepositoryStore(NullLogger.Instance, repository); + var ignore = new IgnoreConfiguration(); + var head = repository.Head.Tip.ShouldNotBeNull(); + + // Excluding a commit must remove exactly that commit and everything it builds upon, which is the same + // set a revision walk hides when the commit is used as the base version source. + foreach (var excluded in sut.GetCommitLog(null, head, ignore)) + { + var actual = sut.GetCommitLog(null, head, ignore, new HashSet { excluded.Sha }) + .Select(element => element.Sha); + var expected = RevisionWalk(repository, excluded, head).Select(element => element.Sha); + + actual.ShouldBe(expected, $"commit log excluding '{excluded.Sha}'"); + } + } + + [Test] + public void DerivedCommitLogStopsExcludingAtIgnoredCommits() + { + using var fixture = CreateBranchedAndMergedRepository(); + var repository = fixture.Repository.ToGitRepository(); + var sut = new RepositoryStore(NullLogger.Instance, repository); + var head = repository.Head.Tip.ShouldNotBeNull(); + + var history = sut.GetCommitLog(null, head, new IgnoreConfiguration()); + + // The oldest commit is an ancestor of every other one, so ignoring the commit right above it shields it + // from the exclusion walk, exactly as the dictionary based pruning this replaced did. + var root = history[^1]; + var shield = history[^2]; + var ignore = new IgnoreConfiguration { Shas = new HashSet { shield.Sha } }; + + var actual = sut.GetCommitLog(null, head, ignore, new HashSet { history[0].Sha }); + + actual.Select(element => element.Sha).ShouldContain(root.Sha); + } + + [Test] + public void DerivedCommitLogHonoursIgnoredCommits() + { + using var fixture = CreateBranchedAndMergedRepository(); + var repository = fixture.Repository.ToGitRepository(); + var sut = new RepositoryStore(NullLogger.Instance, repository); + var head = repository.Head.Tip.ShouldNotBeNull(); + + var ignoredSha = sut.GetCommitLog(null, head, new IgnoreConfiguration()) + .Select(element => element.Sha).Last(); + + var actual = sut + .GetCommitLog(null, head, new IgnoreConfiguration { Shas = new HashSet { ignoredSha } }, NothingExcluded) + .Select(element => element.Sha); + + actual.ShouldNotContain(ignoredSha); + } + + [Test] + public void DerivedCommitLogMatchesRevisionWalkWhenACommitInTheMiddleOfTheHistoryIsIgnored() + { + using var fixture = CreateBranchedAndMergedRepository(); + var repository = fixture.Repository.ToGitRepository(); + var sut = new RepositoryStore(NullLogger.Instance, repository); + var head = repository.Head.Tip.ShouldNotBeNull(); + + var allCommits = sut.GetCommitLog(null, head, new IgnoreConfiguration()); + + // Ignoring a commit must not sever the parent links of the commits which build upon it: the ancestors + // of a base version source have to be removed even when an ignored commit sits between the two. + foreach (var ignoredSha in allCommits.Select(element => element.Sha)) + { + var ignore = new IgnoreConfiguration { Shas = new HashSet { ignoredSha } }; + + foreach (var baseVersionSource in allCommits) + { + var actual = sut.GetCommitLog(baseVersionSource, head, ignore, NothingExcluded) + .Select(element => element.Sha); + var expected = RevisionWalk(repository, baseVersionSource, head) + .Where(element => element.Sha != ignoredSha) + .Select(element => element.Sha); + + actual.ShouldBe( + expected, $"base version source '{baseVersionSource.Sha}' while ignoring '{ignoredSha}'"); + } + } + } + + private static EmptyRepositoryFixture CreateBranchedAndMergedRepository() + { + var fixture = new EmptyRepositoryFixture("main"); + + fixture.MakeACommit("initial"); + fixture.MakeACommit("main one"); + + fixture.BranchTo("develop"); + fixture.MakeACommit("develop one"); + fixture.MakeACommit("develop two"); + + fixture.Checkout("main"); + fixture.MakeACommit("main two"); + + fixture.BranchTo("feature/a"); + fixture.MakeACommit("feature a one"); + + fixture.Checkout("develop"); + fixture.MergeNoFF("feature/a"); + fixture.MakeACommit("develop three"); + + fixture.Checkout("main"); + fixture.MergeNoFF("develop"); + fixture.MakeACommit("main three"); + + return fixture; + } + + private static IEnumerable AllCommits(IGitRepository repository) + => repository.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = repository.Head.Tip }); + + private static IEnumerable RevisionWalk(IGitRepository repository, ICommit? baseVersionSource, ICommit head) + => repository.Commits.QueryBy(new CommitFilter + { + IncludeReachableFrom = head, + ExcludeReachableFrom = baseVersionSource, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time + }); +} diff --git a/src/GitVersion.Core.Tests/Core/RepositoryStoreEqualTimestampTests.cs b/src/GitVersion.Core.Tests/Core/RepositoryStoreEqualTimestampTests.cs new file mode 100644 index 0000000000..adb3c15204 --- /dev/null +++ b/src/GitVersion.Core.Tests/Core/RepositoryStoreEqualTimestampTests.cs @@ -0,0 +1,119 @@ +using GitVersion.Configuration; +using GitVersion.Testing.Extensions; +using LgCommit = LibGit2Sharp.Commit; +using LgSignature = LibGit2Sharp.Signature; +using LgTreeDefinition = LibGit2Sharp.TreeDefinition; + +namespace GitVersion.Tests; + +/// +/// Commits which share a committer timestamp are ordered by the revision walk through the shape of its priority +/// queue, which changes when commits are hidden. The derived commit log therefore only guarantees to return the +/// same commits, not the same order, for such histories. These tests pin that guarantee down. +/// +[TestFixture] +public class RepositoryStoreEqualTimestampTests : TestBase +{ + private static readonly IReadOnlySet NothingExcluded = new HashSet(); + + [Test] + public void DerivedCommitLogReturnsTheSameCommitsWhenTimestampsAreEqual() + { + for (var seed = 0; seed < 25; seed++) + { + using var fixture = new EmptyRepositoryFixture("main"); + var repository = CreateRandomDagWithEqualTimestamps(fixture, seed).ToGitRepository(); + + var sut = new RepositoryStore(NullLogger.Instance, repository); + var ignore = new IgnoreConfiguration(); + var head = repository.Head.Tip.ShouldNotBeNull(); + + var history = sut.GetCommitLog(null, head, ignore); + + // Guards the generator: without merge commits these histories would not exercise the tie-breaking + // this fixture is about. + history.Count(commit => commit.Parents.Count > 1) + .ShouldBeGreaterThan(0, $"seed {seed} produced a history without merge commits"); + + foreach (var baseVersionSource in history) + { + var actual = sut.GetCommitLog(baseVersionSource, head, ignore, NothingExcluded) + .Select(element => element.Sha).OrderBy(element => element, StringComparer.Ordinal); + var expected = sut.GetCommitLog(baseVersionSource, head, ignore) + .Select(element => element.Sha).OrderBy(element => element, StringComparer.Ordinal); + + actual.ShouldBe( + expected, $"seed {seed}, base version source '{baseVersionSource.Sha}'"); + } + } + } + + [Test] + public void DerivedCommitLogIsAValidTopologicalOrderWhenTimestampsAreEqual() + { + for (var seed = 0; seed < 25; seed++) + { + using var fixture = new EmptyRepositoryFixture("main"); + var repository = CreateRandomDagWithEqualTimestamps(fixture, seed).ToGitRepository(); + + var sut = new RepositoryStore(NullLogger.Instance, repository); + var ignore = new IgnoreConfiguration(); + var head = repository.Head.Tip.ShouldNotBeNull(); + + foreach (var baseVersionSource in sut.GetCommitLog(null, head, ignore)) + { + var log = sut.GetCommitLog(baseVersionSource, head, ignore, NothingExcluded); + var positions = log + .Select((commit, index) => (commit.Sha, index)) + .ToDictionary(element => element.Sha, element => element.index, StringComparer.Ordinal); + + // A parent must never be emitted before one of the children which are part of the same log. + foreach (var commit in log) + { + foreach (var parentSha in commit.Parents.Select(parent => parent.Sha).Where(positions.ContainsKey)) + { + positions[parentSha].ShouldBeGreaterThan( + positions[commit.Sha], + $"seed {seed}: parent '{parentSha}' came before its child '{commit.Sha}'"); + } + } + } + } + } + + private static LibGit2Sharp.IRepository CreateRandomDagWithEqualTimestamps(EmptyRepositoryFixture fixture, int seed) + { + var repository = fixture.Repository; + + // The histories have to be reproducible across runs and machines, so the shapes come from a small + // deterministic generator rather than from an unpredictable source of randomness. + var state = (uint)seed + 0x9E3779B9u; + int Next(int exclusiveMaximum) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return (int)(state % (uint)exclusiveMaximum); + } + + var signature = new LgSignature("test", "test@test.io", DateTimeOffset.Now.AddHours(-1)); + var tree = repository.ObjectDatabase.CreateTree(new LgTreeDefinition()); + + var created = new List(); + for (var i = 0; i < 14; i++) + { + // The first commit has nothing to descend from, which Take handles without a special case. + var parentCount = Math.Min(created.Count, 1 + Next(3)); + var parents = created.OrderBy(_ => Next(int.MaxValue)).Take(parentCount); + + created.Add(repository.ObjectDatabase.CreateCommit( + signature, signature, $"commit {i}", tree, parents, prettifyMessage: false)); + } + + var tip = repository.ObjectDatabase.CreateCommit( + signature, signature, "tip", tree, created, prettifyMessage: false); + repository.Refs.Add("refs/heads/main", tip.Id, allowOverwrite: true); + + return repository; + } +} diff --git a/src/GitVersion.Core/Core/Abstractions/IRepositoryStore.cs b/src/GitVersion.Core/Core/Abstractions/IRepositoryStore.cs index 5e897e1f93..acbad99185 100644 --- a/src/GitVersion.Core/Core/Abstractions/IRepositoryStore.cs +++ b/src/GitVersion.Core/Core/Abstractions/IRepositoryStore.cs @@ -35,6 +35,24 @@ public interface IRepositoryStore /// Returns the commits reachable between and , respecting ignore rules. IReadOnlyList GetCommitLog(ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore); + /// + /// Returns the commits reachable between and , + /// respecting ignore rules, with the commits identified by and all of their + /// ancestors left out as well. The ancestor walk of stops at ignored commits, + /// whereas the one of passes through them. + /// + /// + /// This overload derives the result from a single cached revision walk rather than performing one walk per + /// call. The returned commits are the same, but commits which share a committer timestamp may come out in a + /// different order than the plain GetCommitLog overload returns them. + /// + /// Results are cached per instance, so the set must not be modified after it + /// has been passed in. + /// + /// + IReadOnlyList GetCommitLog( + ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore, IReadOnlySet? excludedShas); + /// Returns all commits reachable from the HEAD commit, respecting ignore rules. IReadOnlyList GetCommitsReacheableFromHead(ICommit? headCommit, IIgnoreConfiguration ignore); diff --git a/src/GitVersion.Core/Core/CommitGraph.cs b/src/GitVersion.Core/Core/CommitGraph.cs new file mode 100644 index 0000000000..b31421ae43 --- /dev/null +++ b/src/GitVersion.Core/Core/CommitGraph.cs @@ -0,0 +1,274 @@ +using GitVersion.Extensions; +using GitVersion.Git; + +namespace GitVersion; + +/// +/// An immutable, in-memory projection of the commits reachable from a single head commit. +/// +/// +/// +/// Building the graph costs exactly one revision walk. Once built, the commit log between an arbitrary +/// base commit (exclusive) and the head commit (inclusive) is produced without touching the git object +/// database again. This turns the repeated full revision walks performed while evaluating every version +/// tag into cheap in-memory set operations. +/// +/// +/// The commits are stored in the exact order produced by a Topological | Time walk. Removing the +/// ancestors of a base commit from that sequence selects exactly the commits a walk which hides the base +/// commit returns: the commits reachable from the head but not from the base are closed under taking +/// children, so no child of a retained commit is removed. +/// +/// +/// The resulting order is a valid topological order and matches the walk whenever the committer +/// timestamps of the selected commits are distinct. Commits which share a timestamp may be ordered +/// differently, because the walk breaks such ties through the shape of its priority queue, which changes +/// when commits are hidden. Callers which depend on the relative order of equally timed commits therefore +/// have to use the plain walk instead. +/// +/// +/// The graph deliberately covers the unfiltered history. Ignored commits have to stay part of it, because +/// dropping them would sever the parent links through which the ancestors of a base commit are reached. +/// They are left out of the produced commit log instead, which is where the revision walk dropped them too. +/// +/// +internal sealed class CommitGraph +{ + private readonly ICommit[] commits; + private readonly Dictionary indexBySha; + private readonly int[][] parentIndexes; + + private CommitGraph(ICommit[] commits, Dictionary indexBySha, int[][] parentIndexes) + { + this.commits = commits; + this.indexBySha = indexBySha; + this.parentIndexes = parentIndexes; + } + + /// The commits reachable from the head commit, ordered exactly as the revision walk emitted them. + public IReadOnlyList Commits => this.commits; + + public static CommitGraph Create(IReadOnlyList commitsInWalkOrder) + { + commitsInWalkOrder.NotNull(); + + var commits = commitsInWalkOrder as ICommit[] ?? [.. commitsInWalkOrder]; + + var indexBySha = new Dictionary(commits.Length, StringComparer.Ordinal); + for (var index = 0; index < commits.Length; index++) + { + indexBySha[commits[index].Sha] = index; + } + + var parentIndexes = new int[commits.Length][]; + for (var index = 0; index < commits.Length; index++) + { + var parents = commits[index].Parents; + if (parents.Count == 0) + { + parentIndexes[index] = []; + continue; + } + + var resolved = new List(parents.Count); + foreach (var parent in parents) + { + // A parent outside the graph is unreachable from the head commit and can therefore never + // appear in a commit log derived from this graph. + if (indexBySha.TryGetValue(parent.Sha, out var parentIndex)) + { + resolved.Add(parentIndex); + } + } + + parentIndexes[index] = [.. resolved]; + } + + return new(commits, indexBySha, parentIndexes); + } + + /// Builds a mask flagging the position which each commit of has in this graph. + public bool[] CreateMembershipMask(IEnumerable members) + { + members.NotNull(); + + var mask = new bool[this.commits.Length]; + foreach (var member in members) + { + if (this.indexBySha.TryGetValue(member.Sha, out var index)) + { + mask[index] = true; + } + } + + return mask; + } + + /// + /// Builds a mask flagging every commit which is, or is an ancestor of, one of the commits identified by + /// . When is supplied, only the commits it flags are + /// used as a starting point and only those are traversed, so the walk stops at commits it does not flag. + /// + public bool[] CreateAncestorMask(IEnumerable shas, bool[]? traversable) + { + shas.NotNull(); + + var mask = new bool[this.commits.Length]; + var pending = new Stack(); + + foreach (var sha in shas) + { + if (this.indexBySha.TryGetValue(sha, out var index) && !mask[index] && (traversable is null || traversable[index])) + { + mask[index] = true; + pending.Push(index); + } + } + + while (pending.Count > 0) + { + foreach (var parentIndex in this.parentIndexes[pending.Pop()]) + { + if (mask[parentIndex] || (traversable is not null && !traversable[parentIndex])) + { + continue; + } + + mask[parentIndex] = true; + pending.Push(parentIndex); + } + } + + return mask; + } + + /// + /// Returns the commits of this graph in revision walk order, leaving out + /// together with all of its ancestors, every commit which does not flag and every + /// commit which flags. + /// + public IReadOnlyList GetCommits(ICommit? baseVersionSource, bool[]? included, bool[]? excluded) + { + bool[]? baseAncestors = null; + var baseAncestorCount = 0; + if (baseVersionSource is not null) + { + baseAncestors = new bool[this.commits.Length]; + baseAncestorCount = MarkAncestorsOf(baseVersionSource, baseAncestors); + } + + if (baseAncestorCount == 0 && included is null && excluded is null) + { + return this.commits; + } + + var result = new List(this.commits.Length - baseAncestorCount); + for (var index = 0; index < this.commits.Length; index++) + { + if (included is not null && !included[index]) + { + continue; + } + + if (baseAncestors is not null && baseAncestors[index]) + { + continue; + } + + if (excluded is not null && excluded[index]) + { + continue; + } + + result.Add(this.commits[index]); + } + + return result; + } + + /// + /// Marks and all of its ancestors which belong to this graph and returns the + /// number of commits that were marked. Ignored commits are traversed as well, mirroring a revision walk + /// which hides the commit and everything it builds upon. + /// + private int MarkAncestorsOf(ICommit commit, bool[] mask) + { + var pending = new Stack(); + var marked = 0; + + if (this.indexBySha.TryGetValue(commit.Sha, out var index)) + { + mask[index] = true; + marked++; + pending.Push(index); + } + else + { + marked += SeedFromCommitOutsideTheGraph(commit, mask, pending); + } + + return marked + MarkParents(pending, mask); + } + + /// + /// Marks the ancestors of a commit which is itself not reachable from the head commit, by walking the real + /// commit graph until the traversal re-enters this graph. Every commit it re-enters at is pushed onto + /// , from where the cheap index based walk takes over, because every ancestor of a + /// reachable commit is reachable as well. + /// + private int SeedFromCommitOutsideTheGraph(ICommit commit, bool[] mask, Stack pending) + { + var marked = 0; + var visited = new HashSet(StringComparer.Ordinal) { commit.Sha }; + var outside = new Stack(); + outside.Push(commit); + + while (outside.Count > 0) + { + foreach (var parent in outside.Pop().Parents) + { + if (!this.indexBySha.TryGetValue(parent.Sha, out var parentIndex)) + { + if (visited.Add(parent.Sha)) + { + outside.Push(parent); + } + } + else if (!mask[parentIndex]) + { + mask[parentIndex] = true; + marked++; + pending.Push(parentIndex); + } + } + } + + return marked; + } + + /// + /// Marks everything reachable from through parent links and returns how many + /// commits were newly marked. + /// + private int MarkParents(Stack pending, bool[] mask) + { + var marked = 0; + + while (pending.Count > 0) + { + foreach (var parentIndex in this.parentIndexes[pending.Pop()]) + { + if (mask[parentIndex]) + { + continue; + } + + mask[parentIndex] = true; + marked++; + pending.Push(parentIndex); + } + } + + return marked; + } +} diff --git a/src/GitVersion.Core/Core/RepositoryStore.cs b/src/GitVersion.Core/Core/RepositoryStore.cs index c1564bdea7..9725b75d4e 100644 --- a/src/GitVersion.Core/Core/RepositoryStore.cs +++ b/src/GitVersion.Core/Core/RepositoryStore.cs @@ -9,6 +9,9 @@ internal class RepositoryStore(ILogger logger, IGitRepository r { private readonly ILogger logger = logger.NotNull(); private readonly IGitRepository repository = repository.NotNull(); + private readonly Dictionary commitGraphCache = []; + private readonly Dictionary<(string Sha, IIgnoreConfiguration Ignore), bool[]> includedMaskCache = []; + private readonly Dictionary<(string Sha, IIgnoreConfiguration Ignore, IReadOnlySet ExcludedShas), bool[]> ancestorMaskCache = []; public int UncommittedChangesCount => this.repository.UncommittedChangesCount(); @@ -254,6 +257,10 @@ public IEnumerable FindCommitBranchesBranchedFrom( => FindCommitBranchesBranchedFrom( branch, configuration, excludedBranches, excludeIgnoredBranches: true); + /// + /// Returns the commits reachable from which are not reachable from + /// , in the order the revision walk emits them. + /// public IReadOnlyList GetCommitLog(ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore) { currentCommit.NotNull(); @@ -270,6 +277,54 @@ public IReadOnlyList GetCommitLog(ICommit? baseVersionSource, ICommit c return [.. ignore.Filter(commits)]; } + public IReadOnlyList GetCommitLog( + ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore, IReadOnlySet? excludedShas) + { + currentCommit.NotNull(); + ignore.NotNull(); + + if (excludedShas is null) + { + return GetCommitLog(baseVersionSource, currentCommit, ignore); + } + + var graph = GetCommitGraph(currentCommit); + var included = GetIncludedMask(graph, currentCommit, ignore); + + // Which commits the excluded ones cover does not depend on the base version source, so the mask is + // built once per set of excluded commits instead of once per requested commit log. + var excluded = this.ancestorMaskCache.GetOrAdd( + (currentCommit.Sha, ignore, excludedShas), () => graph.CreateAncestorMask(excludedShas, included)); + + return graph.GetCommits(baseVersionSource, included, excluded); + } + + /// + /// Returns the commits reachable from . The walk is performed once and + /// cached, because the reachable history cannot change while a single version is calculated. + /// + private CommitGraph GetCommitGraph(ICommit currentCommit) + => this.commitGraphCache.GetOrAdd(currentCommit.Sha, () => + { + var filter = new CommitFilter + { + IncludeReachableFrom = currentCommit, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time + }; + + return CommitGraph.Create([.. FilterCommits(filter)]); + }); + + /// + /// Returns the mask of the commits which survive , or when + /// nothing is ignored. + /// + private bool[]? GetIncludedMask(CommitGraph graph, ICommit currentCommit, IIgnoreConfiguration ignore) + => ignore.IsEmpty + ? null + : this.includedMaskCache.GetOrAdd( + (currentCommit.Sha, ignore), () => graph.CreateMembershipMask(ignore.Filter(graph.Commits))); + public IReadOnlyList GetCommitsReacheableFromHead(ICommit? headCommit, IIgnoreConfiguration ignore) { var filter = new CommitFilter diff --git a/src/GitVersion.Core/Core/TaggedSemanticVersionRepository.cs b/src/GitVersion.Core/Core/TaggedSemanticVersionRepository.cs index 131626c776..17ec25cfbe 100644 --- a/src/GitVersion.Core/Core/TaggedSemanticVersionRepository.cs +++ b/src/GitVersion.Core/Core/TaggedSemanticVersionRepository.cs @@ -9,11 +9,11 @@ namespace GitVersion; internal sealed class TaggedSemanticVersionRepository(ILogger logger, IRepositoryStore repositoryStore) : ITaggedSemanticVersionRepository { private readonly ILogger logger = logger.NotNull(); - private readonly ConcurrentDictionary<(IBranch, string, SemanticVersionFormat), IReadOnlyList> + private readonly ConcurrentDictionary<(IBranch, string, SemanticVersionFormat), ILookup> taggedSemanticVersionsOfBranchCache = new(); - private readonly ConcurrentDictionary<(IBranch, string, SemanticVersionFormat), IReadOnlyList<(ICommit Key, SemanticVersionWithTag Value)>> + private readonly ConcurrentDictionary<(IBranch, string, SemanticVersionFormat), ILookup> taggedSemanticVersionsOfMergeTargetCache = new(); - private readonly ConcurrentDictionary<(string, SemanticVersionFormat), IReadOnlyList> + private readonly ConcurrentDictionary<(string, SemanticVersionFormat), ILookup> taggedSemanticVersionsCache = new(); private readonly IRepositoryStore repositoryStore = repositoryStore.NotNull(); @@ -28,7 +28,8 @@ public ILookup GetTaggedSemanticVersionsOfBranc var result = this.taggedSemanticVersionsOfBranchCache.GetOrAdd(new(branch, tagPrefix, format), _ => { isCached = false; - return [.. GetElements().Distinct().OrderByDescending(element => element.Tag.Commit.When)]; + return GetElements().Distinct().OrderByDescending(element => element.Tag.Commit.When) + .ToLookup(element => element.Tag.Commit, element => element); }); if (isCached) @@ -40,7 +41,7 @@ public ILookup GetTaggedSemanticVersionsOfBranc ); } - return result.ToLookup(element => element.Tag.Commit, element => element); + return result; IEnumerable GetElements() { @@ -70,7 +71,8 @@ public ILookup GetTaggedSemanticVersionsOfMerge var result = this.taggedSemanticVersionsOfMergeTargetCache.GetOrAdd(new(branch, tagPrefix, format), _ => { isCached = false; - return [.. GetElements().Distinct().OrderByDescending(element => element.Key.When)]; + return GetElements().Distinct().OrderByDescending(element => element.Key.When) + .ToLookup(element => element.Key, element => element.Value); }); if (isCached) @@ -82,7 +84,7 @@ public ILookup GetTaggedSemanticVersionsOfMerge ); } - return result.ToLookup(element => element.Key, element => element.Value); + return result; IEnumerable<(ICommit Key, SemanticVersionWithTag Value)> GetElements() { @@ -111,7 +113,8 @@ public ILookup GetTaggedSemanticVersions( var result = this.taggedSemanticVersionsCache.GetOrAdd(new(tagPrefix, format), _ => { isCached = false; - return [.. GetElements().OrderByDescending(element => element.Tag.Commit.When)]; + return GetElements().OrderByDescending(element => element.Tag.Commit.When) + .ToLookup(element => element.Tag.Commit, element => element); }); if (isCached) @@ -119,7 +122,7 @@ public ILookup GetTaggedSemanticVersions( this.logger.LogDebug("Returning cached tagged semantic versions. TagPrefix: {TagPrefix} and Format: {Format}", tagPrefix, format); } - return result.ToLookup(element => element.Tag.Commit, element => element); + return result; IEnumerable GetElements() { diff --git a/src/GitVersion.Core/PublicAPI.Unshipped.txt b/src/GitVersion.Core/PublicAPI.Unshipped.txt index af565cd3bc..b28e5eb49b 100644 --- a/src/GitVersion.Core/PublicAPI.Unshipped.txt +++ b/src/GitVersion.Core/PublicAPI.Unshipped.txt @@ -141,3 +141,4 @@ static GitVersion.VersionCalculation.CommitMessageIncrement.operator ==(GitVersi override GitVersion.VersionCalculation.CommitMessageIncrement.GetHashCode() -> int ~override GitVersion.VersionCalculation.CommitMessageIncrement.Equals(object obj) -> bool ~override GitVersion.VersionCalculation.CommitMessageIncrement.ToString() -> string +GitVersion.IRepositoryStore.GetCommitLog(GitVersion.Git.ICommit? baseVersionSource, GitVersion.Git.ICommit! currentCommit, GitVersion.Configuration.IIgnoreConfiguration! ignore, System.Collections.Generic.IReadOnlySet? excludedShas) -> System.Collections.Generic.IReadOnlyList! diff --git a/src/GitVersion.Core/VersionCalculation/IncrementStrategyFinder.cs b/src/GitVersion.Core/VersionCalculation/IncrementStrategyFinder.cs index 4b35dabb2d..53ccb06c49 100644 --- a/src/GitVersion.Core/VersionCalculation/IncrementStrategyFinder.cs +++ b/src/GitVersion.Core/VersionCalculation/IncrementStrategyFinder.cs @@ -13,6 +13,9 @@ internal class IncrementStrategyFinder( private readonly Dictionary commitIncrementCache = []; private readonly Dictionary> headCommitsMapCache = []; private readonly Dictionary headCommitsCache = []; + private readonly Dictionary<(string TagPrefix, SemanticVersionFormat Format, string? Label, IIgnoreConfiguration Ignore), IReadOnlySet> + targetShasCache = []; + private readonly Dictionary<(string Sha, IIgnoreConfiguration Ignore, string Pattern), bool> versionBumpResetCache = []; private readonly IRepositoryStore repositoryStore = repositoryStore.NotNull(); private readonly ITaggedSemanticVersionRepository taggedSemanticVersionRepository = taggedSemanticVersionRepository.NotNull(); @@ -87,12 +90,10 @@ public VersionField DetermineIncrementedField( } IEnumerable commits = GetCommitHistory( - tagPrefix: configuration.TagPrefixPattern, - semanticVersionFormat: configuration.SemanticVersionFormat, + configuration: configuration, baseVersionSource: baseVersionSource, currentCommit: currentCommit, - label: label, - ignore: configuration.Ignore + label: label ); if (configuration.CommitMessageIncrementing == CommitMessageIncrementMode.MergeMessageOnly) @@ -110,23 +111,73 @@ private static Regex TryGetRegexOrDefault(string? messageRegex, Regex defaultReg ? defaultRegex : RegexPatterns.Cache.GetOrAdd(messageRegex); - private Dictionary.ValueCollection GetCommitHistory(string? tagPrefix, SemanticVersionFormat semanticVersionFormat, - ICommit? baseVersionSource, ICommit currentCommit, string? label, IIgnoreConfiguration ignore) + private IReadOnlyList GetCommitHistory( + EffectiveConfiguration configuration, ICommit? baseVersionSource, ICommit currentCommit, string? label) { - var targetShas = new Lazy>(() => - [.. this.taggedSemanticVersionRepository + var tagPrefix = configuration.TagPrefixPattern; + var semanticVersionFormat = configuration.SemanticVersionFormat; + var ignore = configuration.Ignore; + + // Commits which are already covered by another version tag, and everything they build upon, must not + // contribute to the increment. The set of those tags does not depend on the base version source, so it + // is resolved once per tag configuration instead of once per commit log. A null label is not the same + // as an empty one -- it matches every pre-release label -- so it has to stay distinct in the key. + var targetShas = this.targetShasCache.GetOrAdd((tagPrefix ?? string.Empty, semanticVersionFormat, label, ignore), () => + (IReadOnlySet)this.taggedSemanticVersionRepository .GetTaggedSemanticVersions(tagPrefix, semanticVersionFormat, ignore) .SelectMany(versionWithTags => versionWithTags) .Where(versionWithTag => versionWithTag.Value.IsMatchForBranchSpecificLabel(label)) - .Select(versionWithTag => versionWithTag.Tag.TargetSha)] - ); + .Select(versionWithTag => versionWithTag.Tag.TargetSha) + .ToHashSet(StringComparer.Ordinal)); + + if (ContainsVersionBumpReset(configuration, currentCommit)) + { + return GetCommitHistoryFromIndividualWalk(baseVersionSource, currentCommit, ignore, targetShas); + } + return this.repositoryStore.GetCommitLog(baseVersionSource, currentCommit, ignore, targetShas); + } + + /// + /// Reports whether any commit reachable from resets the accumulated version + /// bump. + /// + /// + /// The optimized commit log is a subsequence of one revision walk. Its contents are identical to a walk which + /// hides the base version source, but commits sharing a committer timestamp may be emitted in a different + /// order, because the ordering of equally timed commits depends on the shape of the priority queue the walk + /// maintains. consolidates the increments of all commits, which does not + /// depend on their order, unless a commit resets the accumulated bump: it stops at the first such commit. Only + /// then does the order become part of the result, so only then is the slower walk per base version source used. + /// + private bool ContainsVersionBumpReset(EffectiveConfiguration configuration, ICommit currentCommit) + { + var pattern = configuration.VersionBumpResetMessage + ?? RegexPatterns.VersionCalculation.DefaultVersionBumpResetRegexPattern; + + return this.versionBumpResetCache.GetOrAdd((currentCommit.Sha, configuration.Ignore, pattern), () => + { + var regex = TryGetRegexOrDefault( + configuration.VersionBumpResetMessage, RegexPatterns.VersionCalculation.DefaultVersionBumpResetRegex); + + return this.repositoryStore.GetCommitLog(null, currentCommit, configuration.Ignore) + .Any(commit => regex.IsMatch(commit.Message)); + }); + } + + /// + /// The original commit history lookup, which asks for one revision walk per base version source and prunes the + /// commits already covered by a version tag from the result. + /// + private IReadOnlyList GetCommitHistoryFromIndividualWalk( + ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore, IReadOnlySet targetShas) + { var intermediateCommits = this.repositoryStore.GetCommitLog(baseVersionSource, currentCommit, ignore); var commitLog = intermediateCommits.ToDictionary(element => element.Id.Sha); foreach (var intermediateCommit in intermediateCommits.Reverse()) { - if (!targetShas.Value.Contains(intermediateCommit.Sha) || !commitLog.Remove(intermediateCommit.Sha)) + if (!targetShas.Contains(intermediateCommit.Sha) || !commitLog.Remove(intermediateCommit.Sha)) { continue; } @@ -143,7 +194,7 @@ [.. this.taggedSemanticVersionRepository } } - return commitLog.Values; + return [.. commitLog.Values]; } ///