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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions src/GitVersion.Core.Tests/Core/RepositoryStoreCommitLogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
using GitVersion.Configuration;
using GitVersion.Git;
using GitVersion.Testing.Extensions;

namespace GitVersion.Tests;

/// <summary>
/// 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.
/// </summary>
[TestFixture]
public class RepositoryStoreCommitLogTests : TestBase
{
private static readonly IReadOnlySet<string> NothingExcluded = new HashSet<string>();

[Test]
public void DerivedCommitLogMatchesRevisionWalkForEveryBaseVersionSource()
{
using var fixture = CreateBranchedAndMergedRepository();
var repository = fixture.Repository.ToGitRepository();
var sut = new RepositoryStore(NullLogger<RepositoryStore>.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<RepositoryStore>.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<RepositoryStore>.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<string> { 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<RepositoryStore>.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<string> { shield.Sha } };

var actual = sut.GetCommitLog(null, head, ignore, new HashSet<string> { 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<RepositoryStore>.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<string> { 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<RepositoryStore>.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<string> { 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<ICommit> AllCommits(IGitRepository repository)
=> repository.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = repository.Head.Tip });

private static IEnumerable<ICommit> RevisionWalk(IGitRepository repository, ICommit? baseVersionSource, ICommit head)
=> repository.Commits.QueryBy(new CommitFilter
{
IncludeReachableFrom = head,
ExcludeReachableFrom = baseVersionSource,
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time
});
}
119 changes: 119 additions & 0 deletions src/GitVersion.Core.Tests/Core/RepositoryStoreEqualTimestampTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[TestFixture]
public class RepositoryStoreEqualTimestampTests : TestBase
{
private static readonly IReadOnlySet<string> NothingExcluded = new HashSet<string>();

[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<RepositoryStore>.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<RepositoryStore>.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<LgCommit>();
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;
}
}
18 changes: 18 additions & 0 deletions src/GitVersion.Core/Core/Abstractions/IRepositoryStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ public interface IRepositoryStore
/// <summary>Returns the commits reachable between <paramref name="baseVersionSource"/> and <paramref name="currentCommit"/>, respecting ignore rules.</summary>
IReadOnlyList<ICommit> GetCommitLog(ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore);

/// <summary>
/// Returns the commits reachable between <paramref name="baseVersionSource"/> and <paramref name="currentCommit"/>,
/// respecting ignore rules, with the commits identified by <paramref name="excludedShas"/> and all of their
/// ancestors left out as well. The ancestor walk of <paramref name="excludedShas"/> stops at ignored commits,
/// whereas the one of <paramref name="baseVersionSource"/> passes through them.
/// </summary>
/// <remarks>
/// 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 <c>GetCommitLog</c> overload returns them.
/// <para>
/// Results are cached per <paramref name="excludedShas"/> instance, so the set must not be modified after it
/// has been passed in.
/// </para>
/// </remarks>
IReadOnlyList<ICommit> GetCommitLog(
ICommit? baseVersionSource, ICommit currentCommit, IIgnoreConfiguration ignore, IReadOnlySet<string>? excludedShas);

/// <summary>Returns all commits reachable from the HEAD commit, respecting ignore rules.</summary>
IReadOnlyList<ICommit> GetCommitsReacheableFromHead(ICommit? headCommit, IIgnoreConfiguration ignore);

Expand Down
Loading
Loading