Skip to content
Merged
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary>Example: Bash</summary>

```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
```
</details>

<details>
<summary>Example: GitHub Actions</summary>

```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

```
</details>
5 changes: 4 additions & 1 deletion source/OctoVersion.Core/Configuration/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -85,4 +88,4 @@ public IEnumerable<ValidationResult> 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) });
}
}
}
2 changes: 1 addition & 1 deletion source/OctoVersion.Core/OctoVersionRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,32 @@ public class VersionCalculatorFactory
{
readonly ILogger _logger = Log.ForContext<VersionCalculatorFactory>();
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"))
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -84,4 +104,4 @@ public VersionCalculator Create()
var calculator = new VersionCalculator(commits.Values.ToArray(), currentCommitHash);
return calculator;
}
}
}
29 changes: 29 additions & 0 deletions source/OctoVersion.Tests/ConfigurationBootstrapperFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,33 @@ public static IEnumerable<object[]> NonPreReleaseTagsEnvironmentVariableTestCase
new[] { "main", "release" }
};
}

[Fact]
public void AllowShallowClone_DefaultsToFalse()
{
var args = new[] { "--CurrentBranch", "main" };
var (appSettings, _) = ConfigurationBootstrapper.Bootstrap<AppSettings>(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<AppSettings>(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>();
appSettings.AllowShallowClone.ShouldBe(expectedValue);
}
}
210 changes: 210 additions & 0 deletions source/OctoVersion.Tests/WhenCalculatingVersionFromShallowClone.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using System;
using OctoVersion.Core.VersionNumberCalculation;
using Shouldly;
using Xunit;

namespace OctoVersion.Tests;

/// <summary>
/// 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.
/// </summary>
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);
}
}
Loading