diff --git a/README.md b/README.md index d96ed04..6f4e7ad 100644 --- a/README.md +++ b/README.md @@ -256,3 +256,67 @@ Minor bumps can be achieved using either of the following within one of the comm ``` Patch bumps occur per-commit, and do not require specific messages to occur. + +## Advanced Use Cases + +### Using Shallow Clones + +For very large repositories with long histories it is often desirable to be able to use shallow clones. +This is supported via the `--AllowShallowClone=true` argument, however it is critical to understand that there must be enough commits to find a previous version to use as the base. +In a scenario where the tags for the previous version are always on your main branch's `HEAD` and you always branch from there. + +> [!WARNING] +> if you cannot guarantee that the tags will be present, the generated version is likely to be incorrect. + +
+ Example: Bash + +```bash +# Optionally, also use sparse checkout to only fetch some static, known, file that is small since the contents of the repo are unimportant for octoversion to run. +# Only for cases where you don't need to checkout the whole repo +git sparse-checkout set reporoot + +DEPTH=20 +while true; do +git fetch --depth=$DEPTH --tags origin main featureBranch +if git describe --tags --abbrev=0 featureBranch 2>/dev/null; then + break +fi +DEPTH=$((DEPTH * 2)) +if [ $DEPTH -gt 1000 ]; then + echo "Reached maximum depth without finding a tag, stopping fetch" + exit 1 +fi +done +``` +
+ +
+ Example: GitHub Actions + +```yaml +- name: checkout repo source code with fast checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: true + token: ${{ secrets.GITHUB_TOKEN }} + sparse-checkout: reporoot + +- name: fast-checkout checking out default branch to find common history + run: | + DEPTH=20 + while true; do + git fetch --depth=$DEPTH --tags origin ${{ github.event.repository.default_branch }} +${{ github.sha }}:${{ github.ref }} + if git describe --tags --abbrev=0 ${{ github.ref }} 2>/dev/null; then + break + fi + DEPTH=$((DEPTH * 2)) + if [ $DEPTH -gt 1000 ]; then + echo "Reached maximum depth without finding a tag, stopping fetch" + exit 1 + fi + done + +``` +
diff --git a/source/OctoVersion.Core/Configuration/AppSettings.cs b/source/OctoVersion.Core/Configuration/AppSettings.cs index ebb915e..7c00f18 100644 --- a/source/OctoVersion.Core/Configuration/AppSettings.cs +++ b/source/OctoVersion.Core/Configuration/AppSettings.cs @@ -37,6 +37,9 @@ public class AppSettings : IAppSettings, IValidatableObject public string? OutputJsonFile { get; set; } + // defaults to false - throw an exception if a shallow clone is detected + public bool AllowShallowClone { get; set; } + public void ApplyDefaultsIfRequired() { if (!NonPreReleaseTags.Any()) @@ -85,4 +88,4 @@ public IEnumerable Validate(ValidationContext validationContex if (string.IsNullOrWhiteSpace(CurrentBranch) && string.IsNullOrWhiteSpace(FullSemVer)) yield return new ValidationResult($"At least one of {nameof(CurrentBranch)} or {nameof(FullSemVer)} must be provided.", new[] { nameof(CurrentBranch), nameof(FullSemVer) }); } -} \ No newline at end of file +} diff --git a/source/OctoVersion.Core/OctoVersionRunner.cs b/source/OctoVersion.Core/OctoVersionRunner.cs index 7fdaf18..6f8b7b3 100644 --- a/source/OctoVersion.Core/OctoVersionRunner.cs +++ b/source/OctoVersion.Core/OctoVersionRunner.cs @@ -68,7 +68,7 @@ public void Run(out OctoVersionInfo versionInfo) ? currentDirectory : appSettings.RepositoryPath; - var versionCalculatorFactory = new VersionCalculatorFactory(repositorySearchPath); + var versionCalculatorFactory = new VersionCalculatorFactory(repositorySearchPath, appSettings.AllowShallowClone); var calculator = versionCalculatorFactory.Create(); var version = calculator.GetVersion(); var currentSha = calculator.CurrentCommitHash; diff --git a/source/OctoVersion.Core/VersionNumberCalculation/VersionCalculatorFactory.cs b/source/OctoVersion.Core/VersionNumberCalculation/VersionCalculatorFactory.cs index a4ec6e1..2bd3dd8 100644 --- a/source/OctoVersion.Core/VersionNumberCalculation/VersionCalculatorFactory.cs +++ b/source/OctoVersion.Core/VersionNumberCalculation/VersionCalculatorFactory.cs @@ -12,20 +12,32 @@ public class VersionCalculatorFactory { readonly ILogger _logger = Log.ForContext(); readonly Repository _repository; + readonly bool _allowShallowClone; public VersionCalculatorFactory(string repositorySearchPath) + : this(repositorySearchPath, allowShallowClone: false) + { + } + + public VersionCalculatorFactory(string repositorySearchPath, bool allowShallowClone) { var gitRepositoryPath = Repository.Discover(repositorySearchPath); if (gitRepositoryPath == null) throw new RepositoryNotFoundException("Unable to resolve Git repository path."); _logger.Debug("Located Git repository in {GitRepositoryPath}", gitRepositoryPath); _repository = new Repository(gitRepositoryPath); + _allowShallowClone = allowShallowClone; } public VersionCalculator Create() { if (_repository.Info.IsShallow) - throw new RepositoryIsShallowCloneException("This repository is a shallow clone; it does not contain enough history to resolve the version correctly."); + { + if (_allowShallowClone) + _logger.Warning("This repository is a shallow clone - version calculation may be inaccurate if the commit history does not reach a commit containing version information"); + else + throw new RepositoryIsShallowCloneException("This repository is a shallow clone; it does not contain enough history to resolve the version correctly."); + } Commit[] allCommits; using (_logger.BeginTimedOperation("Loading commits")) @@ -52,7 +64,15 @@ public VersionCalculator Create() var simpleCommit = commits[commit.Sha]; foreach (var parent in commit.Parents) { - var simpleParent = commits[parent.Sha]; + if (!commits.TryGetValue(parent.Sha, out var simpleParent)) + { + // In a shallow clone, boundary commits may reference parents outside the available history. Skip them rather than blowing up with a KeyNotFoundException. + if (_allowShallowClone) + continue; + + throw new KeyNotFoundException("Unable to find parent commit with hash " + parent.Sha); + } + simpleCommit.AddParent(simpleParent); } } @@ -84,4 +104,4 @@ public VersionCalculator Create() var calculator = new VersionCalculator(commits.Values.ToArray(), currentCommitHash); return calculator; } -} \ No newline at end of file +} diff --git a/source/OctoVersion.Tests/ConfigurationBootstrapperFixture.cs b/source/OctoVersion.Tests/ConfigurationBootstrapperFixture.cs index f0ea542..a43e0a5 100644 --- a/source/OctoVersion.Tests/ConfigurationBootstrapperFixture.cs +++ b/source/OctoVersion.Tests/ConfigurationBootstrapperFixture.cs @@ -154,4 +154,33 @@ public static IEnumerable NonPreReleaseTagsEnvironmentVariableTestCase new[] { "main", "release" } }; } + + [Fact] + public void AllowShallowClone_DefaultsToFalse() + { + var args = new[] { "--CurrentBranch", "main" }; + var (appSettings, _) = ConfigurationBootstrapper.Bootstrap(args); + appSettings.AllowShallowClone.ShouldBe(false); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + public void WhenAllowShallowCloneIsPassedViaCommandLine(string argValue, bool expectedValue) + { + var args = new[] { "--CurrentBranch", "main", "--AllowShallowClone", argValue }; + var (appSettings, _) = ConfigurationBootstrapper.Bootstrap(args); + appSettings.AllowShallowClone.ShouldBe(expectedValue); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + public void WhenAllowShallowCloneIsPassedViaEnvironmentVariable(string envValue, bool expectedValue) + { + Environment.SetEnvironmentVariable("OCTOVERSION_CurrentBranch", "main"); + Environment.SetEnvironmentVariable("OCTOVERSION_AllowShallowClone", envValue); + var (appSettings, _) = ConfigurationBootstrapper.Bootstrap(); + appSettings.AllowShallowClone.ShouldBe(expectedValue); + } } \ No newline at end of file diff --git a/source/OctoVersion.Tests/WhenCalculatingVersionFromShallowClone.cs b/source/OctoVersion.Tests/WhenCalculatingVersionFromShallowClone.cs new file mode 100644 index 0000000..645fe84 --- /dev/null +++ b/source/OctoVersion.Tests/WhenCalculatingVersionFromShallowClone.cs @@ -0,0 +1,210 @@ +using System; +using OctoVersion.Core.VersionNumberCalculation; +using Shouldly; +using Xunit; + +namespace OctoVersion.Tests; + +/// +/// Tests for VersionCalculator when the commit graph has been truncated, as happens +/// when VersionCalculatorFactory silently skips parents that fall outside a shallow +/// clone's available history. +/// +public class WhenCalculatingVersionFromShallowClone +{ + static SimpleCommit MakeCommit(string hash, DateTimeOffset timestamp, string message = "normal commit") + => new(hash, message, timestamp, false, false); + + static SimpleCommit MakeMajorBumpCommit(string hash, DateTimeOffset timestamp) + => new(hash, "+semver: major", timestamp, true, false); + + static SimpleCommit MakeMinorBumpCommit(string hash, DateTimeOffset timestamp) + => new(hash, "+semver: minor", timestamp, false, true); + + [Fact] + public void WhenShallowBoundaryHasVersionTag_VersionIsCalculatedRelativeToTag() + { + // Simulates: + // C (no parents — shallow boundary, tagged 2.0.0) + // B (parent: C) + // A (parent: B) ← HEAD + // Expected: HEAD = 2.0.2 + + var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var commitC = MakeCommit("ccc", baseTime); + var commitB = MakeCommit("bbb", baseTime.AddMinutes(1)); + var commitA = MakeCommit("aaa", baseTime.AddMinutes(2)); + + commitC.TagWith(new SimpleVersion(2, 0, 0)); + commitB.AddParent(commitC); + commitA.AddParent(commitB); + + var calculator = new VersionCalculator([commitA, commitB, commitC], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(2); + version.Minor.ShouldBe(0); + version.Patch.ShouldBe(2); + } + + [Fact] + public void WhenShallowBoundaryHasNoTag_VersionCountsFromZero() + { + // Simulates: + // B (no parents — shallow boundary, no tag) + // A (parent: B) ← HEAD + // The shallow boundary commit itself counts as 0.0.1, so HEAD = 0.0.2 + + var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var commitB = MakeCommit("bbb", baseTime); + var commitA = MakeCommit("aaa", baseTime.AddMinutes(1)); + + commitA.AddParent(commitB); + + var calculator = new VersionCalculator([commitA, commitB], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(0); + version.Minor.ShouldBe(0); + version.Patch.ShouldBe(2); + } + + [Fact] + public void WhenShallowBoundaryIsTheCurrentCommit_VersionIsCalculatedFromZero() + { + // Simulates a very shallow clone — the HEAD commit itself is the boundary (no parents). + // A (no parents — shallow boundary) ← HEAD + // Expected: 0.0.1 + + var commitA = MakeCommit("aaa", new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + var calculator = new VersionCalculator([commitA], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(0); + version.Minor.ShouldBe(0); + version.Patch.ShouldBe(1); + } + + [Fact] + public void WhenMergeCommitHasOneParentCutByShallowClone_VersionUsesRemainingParent() + { + // Simulates a merge where one branch was deeper than the shallow depth and its + // root was stripped. The factory's TryGetValue skip means only one parent is linked. + // + // C (no parents — shallow boundary, tagged 3.0.0) + // B (parent: C) + // A (parent: B — the other merge parent was outside shallow history) ← HEAD + + var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var commitC = MakeCommit("ccc", baseTime); + var commitB = MakeCommit("bbb", baseTime.AddMinutes(1)); + var commitA = MakeCommit("aaa", baseTime.AddMinutes(2)); + + commitC.TagWith(new SimpleVersion(3, 0, 0)); + commitB.AddParent(commitC); + // commitA is a merge commit, but its other parent (e.g. "ddd") was outside the + // shallow history so the factory skipped it — only commitB is linked. + commitA.AddParent(commitB); + + var calculator = new VersionCalculator([commitA, commitB, commitC], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(3); + version.Minor.ShouldBe(0); + version.Patch.ShouldBe(2); + } + + [Fact] + public void WhenVersionTagExistsWithinShallowHistory_TagTakesPrecedenceOverCountedCommits() + { + // Confirms that when a version tag exists somewhere in the available (truncated) + // history, it still wins over the commit-counting approach — shallow or not. + // + // D (no parents — shallow boundary) + // C (parent: D, tagged 1.5.0) + // B (parent: C) + // A (parent: B) ← HEAD + // Expected: HEAD = 1.5.2 + + var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var commitD = MakeCommit("ddd", baseTime); + var commitC = MakeCommit("ccc", baseTime.AddMinutes(1)); + var commitB = MakeCommit("bbb", baseTime.AddMinutes(2)); + var commitA = MakeCommit("aaa", baseTime.AddMinutes(3)); + + commitC.TagWith(new SimpleVersion(1, 5, 0)); + commitC.AddParent(commitD); + commitB.AddParent(commitC); + commitA.AddParent(commitB); + + var calculator = new VersionCalculator([commitA, commitB, commitC, commitD], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(1); + version.Minor.ShouldBe(5); + version.Patch.ShouldBe(2); + } + + [Fact] + public void WhenMajorBumpCommitIsAboveShallowBoundary_MajorVersionIsIncremented() + { + // Confirms that semver bump semantics still work correctly when the + // base commit chain starts from a shallow boundary. + // + // C (no parents — shallow boundary, tagged 1.0.0) + // B (parent: C, +semver: major) + // A (parent: B) ← HEAD + // Expected: B = 2.0.0, HEAD = 2.0.1 + + var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var commitC = MakeCommit("ccc", baseTime); + var commitB = MakeMajorBumpCommit("bbb", baseTime.AddMinutes(1)); + var commitA = MakeCommit("aaa", baseTime.AddMinutes(2)); + + commitC.TagWith(new SimpleVersion(1, 0, 0)); + commitB.AddParent(commitC); + commitA.AddParent(commitB); + + var calculator = new VersionCalculator([commitA, commitB, commitC], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(2); + version.Minor.ShouldBe(0); + version.Patch.ShouldBe(1); + } + + [Fact] + public void WhenMinorBumpCommitIsAboveShallowBoundary_MinorVersionIsIncremented() + { + // Confirms that minor version bumps work correctly when the + // base commit chain starts from a shallow boundary. + // + // C (no parents — shallow boundary, tagged 1.0.0) + // B (parent: C, +semver: minor) + // A (parent: B) ← HEAD + // Expected: B = 1.1.0, HEAD = 1.1.1 + + var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var commitC = MakeCommit("ccc", baseTime); + var commitB = MakeMinorBumpCommit("bbb", baseTime.AddMinutes(1)); + var commitA = MakeCommit("aaa", baseTime.AddMinutes(2)); + + commitC.TagWith(new SimpleVersion(1, 0, 0)); + commitB.AddParent(commitC); + commitA.AddParent(commitB); + + var calculator = new VersionCalculator([commitA, commitB, commitC], commitA.Hash); + var version = calculator.GetVersion(); + + version.Major.ShouldBe(1); + version.Minor.ShouldBe(1); + version.Patch.ShouldBe(1); + } +}