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];
}
///