diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 14433ef8..73b3ed96 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -39,7 +39,7 @@ stages: matrix: linux: imageName: 'ubuntu-20.04' - testModifier: -f netcoreapp3.1 + testModifier: -f net5.0 windows: imageName: 'windows-2019' testModifier: @@ -58,10 +58,17 @@ stages: displayName: Configure git commit author for testing - task: UseDotNet@2 - displayName: Install .NET Core SDK 3.1.100 + displayName: Install .NET Core SDK 5.0.100 inputs: packageType: sdk - version: 3.1.100 + version: 5.0.100 + + - task: UseDotNet@2 + displayName: Install .NET Core 3.1 + inputs: + packageType: runtime + version: 3.1.x + - script: dotnet --info displayName: Show dotnet SDK info @@ -107,8 +114,8 @@ stages: --filter "TestCategory!=FailsOnAzurePipelines" --logger "trx;LogFileName=$(Build.ArtifactStagingDirectory)/TestLogs/TestResults.trx" --results-directory $(Build.ArtifactStagingDirectory)/CodeCoverage/ - --collect:"XPlat Code Coverage" - -- + --collect:"XPlat Code Coverage" + -- RunConfiguration.DisableAppDomain=true displayName: Run tests workingDirectory: src @@ -197,6 +204,7 @@ stages: condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['System.TeamFoundationCollectionUri'], 'https://dev.azure.com/andrewarnott/'), ne(variables['Build.Reason'], 'PullRequest')) - stage: Test + displayName: Functional testing jobs: - job: linux strategy: @@ -229,3 +237,79 @@ stages: vmImage: macOS-10.15 steps: - template: azure-pipelines/xplattest-pipeline.yml + +- stage: PerfAnalysis + displayName: Perf analysis + dependsOn: [] + jobs: + - job: PerfTest + strategy: + matrix: + ubuntu: + imageName: ubuntu-18.04 + repoDir: '~/git' + windows: + imageName: windows-2019 + repoDir: '${USERPROFILE}/source/repos' + macOS: + imageName: macOS-10.15 + repoDir: '~/git' + pool: + vmImage: $(imageName) + steps: + - task: UseDotNet@2 + displayName: Install .NET Core SDK 2.1.811 + inputs: + packageType: sdk + version: 2.1.811 + - task: UseDotNet@2 + displayName: Install .NET Core SDK 3.1.100 + inputs: + packageType: sdk + version: 3.1.100 + - task: UseDotNet@2 + displayName: Install .NET Core SDK 5.0.100 + inputs: + packageType: sdk + version: 5.0.100 + - script: dotnet --info + displayName: Show dotnet SDK info + - bash: | + mkdir -p $(repoDir) + git clone https://github.com/xunit/xunit $(repoDir)/xunit + git clone https://github.com/gimlichael/Cuemon $(repoDir)/Cuemon + git clone https://github.com/kerryjiang/SuperSocket $(repoDir)/SuperSocket + git clone https://github.com/dotnet/NerdBank.GitVersioning $(repoDir)/NerdBank.GitVersioning + displayName: Clone test repositories + - script: | + dotnet build -c Release + workingDirectory: src/ + displayName: Build in Release mode + - script: | + dotnet run -c Release -f netcoreapp3.1 -- --filter GetVersionBenchmarks --artifacts $(Build.ArtifactStagingDirectory)/benchmarks/packed/$(imageName) + workingDirectory: src/NerdBank.GitVersioning.Benchmarks + displayName: Run benchmarks (packed) + - bash: | + cd $(repoDir)/xunit + git unpack-objects < .git/objects/pack/*.pack + + cd $(repoDir)/Cuemon + git unpack-objects < .git/objects/pack/*.pack + + cd $(repoDir)/SuperSocket + git unpack-objects < .git/objects/pack/*.pack + + cd $(repoDir)/NerdBank.GitVersioning + git unpack-objects < .git/objects/pack/*.pack + displayName: Unpack Git repositories + - script: | + dotnet run -c Release -f netcoreapp3.1 -- --filter GetVersionBenchmarks --artifacts $(Build.ArtifactStagingDirectory)/benchmarks/unpacked/$(imageName) + workingDirectory: src/NerdBank.GitVersioning.Benchmarks + displayName: Run benchmarks (unpacked) + - task: PublishBuildArtifacts@1 + inputs: + PathtoPublish: $(Build.ArtifactStagingDirectory)/benchmarks + ArtifactName: benchmarks + ArtifactType: Container + displayName: Publish benchmarks artifacts + condition: succeededOrFailed() diff --git a/doc/nuget-acquisition.md b/doc/nuget-acquisition.md index 6cb90156..6af5e325 100644 --- a/doc/nuget-acquisition.md +++ b/doc/nuget-acquisition.md @@ -14,9 +14,10 @@ If in a project that uses PackageReference for this package reference, you shoul After installing this NuGet package, you may need to configure the version generation logic in order for it to work properly. -When using packages.config, the configuration is handled automatically via the tools\Install.ps1 script. -When using project.json or PackageReference, you can run the script tools\Create-VersionFile.ps1 to help -you create the version.json file and remove the old assembly attributes. +We recommend installing the `nbgv` tool using `dotnet tool install -g nbgv`. +Then use `nbgv install` to add the package reference and `version.json` file to your repo. +But you can simply add the package reference yourself, and create the `version.json` in your repo +with content conforming to [this doc](versionJson.md). The scripts will look for the presence of a version.json or version.txt file. If one already exists, nothing happens. If the version file does not exist, diff --git a/global.json b/global.json index e9aac8c2..ee8f712f 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "3.1.100" + "version": "5.0.100" } } diff --git a/src/Cake.GitVersioning/Cake.GitVersioning.csproj b/src/Cake.GitVersioning/Cake.GitVersioning.csproj index c97f2355..ed05a5a5 100644 --- a/src/Cake.GitVersioning/Cake.GitVersioning.csproj +++ b/src/Cake.GitVersioning/Cake.GitVersioning.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/Cake.GitVersioning/GitVersioningAliases.cs b/src/Cake.GitVersioning/GitVersioningAliases.cs index b7e8a3b2..346420e0 100644 --- a/src/Cake.GitVersioning/GitVersioningAliases.cs +++ b/src/Cake.GitVersioning/GitVersioningAliases.cs @@ -27,11 +27,11 @@ public static class GitVersioningAliases /// Information(GetVersioningGetVersion().SemVer2) /// }); /// - /// The context. + /// The context. /// Directory to start the search for version.json. /// The version information from Git Versioning. [CakeMethodAlias] - public static VersionOracle GitVersioningGetVersion(this ICakeContext context, string projectDirectory = ".") + public static VersionOracle GitVersioningGetVersion(this ICakeContext cakeContext, string projectDirectory = ".") { var fullProjectDirectory = (new DirectoryInfo(projectDirectory)).FullName; @@ -42,30 +42,8 @@ public static VersionOracle GitVersioningGetVersion(this ICakeContext context, s throw new InvalidOperationException("Could not locate the Cake.GitVersioning library"); } - // Even after adding the folder containing the native libgit2 DLL to the PATH, DllNotFoundException is still thrown - // Workaround this by copying the contents of the found folder to the current directory - GitExtensions.HelpFindLibGit2NativeBinaries(directoryName, out var attemptedDirectory); - - // The HelpFindLibGit2NativeBinaries method throws if the directory does not exist - var directoryInfo = new DirectoryInfo(attemptedDirectory); - - // There should only be a single file in the directory, but we do not know its extension - // So, we will just get a list of all files rather than trying to determine the correct name and extension - // If there are other files there for some reason, it should not matter as long as we don't overwrite anything in the current directory - var fileInfos = directoryInfo.GetFiles(); - - foreach (var fileInfo in fileInfos) - { - // Copy the file to the Cake.GitVersioning DLL directory, without overwriting anything - string destFileName = Path.Combine(directoryName, fileInfo.Name); - - if (!File.Exists(destFileName)) - { - File.Copy(fileInfo.FullName, destFileName); - } - } - - return VersionOracle.Create(fullProjectDirectory, null, CloudBuild.Active); + var gitContext = GitContext.Create(fullProjectDirectory); + return new VersionOracle(gitContext, cloudBuild: CloudBuild.Active); } } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8d0b98b4..79c149b5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,7 +4,7 @@ $(MSBuildThisFileDirectory)..\obj\$(MSBuildProjectName)\ $(MSBuildThisFileDirectory)..\bin\$(MSBuildProjectName)\$(Configuration)\ $(MSBuildThisFileDirectory)..\wiki\api - 7.3 + 8.0 true $(MSBuildThisFileDirectory)strongname.snk @@ -24,6 +24,7 @@ 2.0.306 + diff --git a/src/NerdBank.GitVersioning.Benchmarks/GetVersionBenchmarks.cs b/src/NerdBank.GitVersioning.Benchmarks/GetVersionBenchmarks.cs new file mode 100644 index 00000000..8445996c --- /dev/null +++ b/src/NerdBank.GitVersioning.Benchmarks/GetVersionBenchmarks.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace Nerdbank.GitVersioning.Benchmarks +{ + [SimpleJob(RuntimeMoniker.NetCoreApp31, baseline: true)] + [SimpleJob(RuntimeMoniker.NetCoreApp21)] + [SimpleJob(RuntimeMoniker.NetCoreApp50)] + [SimpleJob(RuntimeMoniker.Net461)] + public class GetVersionBenchmarks + { + // You must manually clone these repositories: + // - On Windows, to %USERPROFILE%\Source\Repose + // - On Unix, to ~/git/ + [Params( + "xunit", + "Cuemon", + "SuperSocket", + "NerdBank.GitVersioning")] + public string ProjectDirectory; + + public Version Version { get; set; } + + [Benchmark(Baseline = true)] + public void GetVersionLibGit2() + { + using var context = GitContext.Create(GetPath(this.ProjectDirectory), writable: true); + var oracle = new VersionOracle(context, cloudBuild: null); + this.Version = oracle.Version; + } + + [Benchmark] + public void GetVersionManaged() + { + using var context = GitContext.Create(GetPath(this.ProjectDirectory), writable: false); + var oracle = new VersionOracle(context, cloudBuild: null); + this.Version = oracle.Version; + } + + private static string GetPath(string repositoryName) + { + string path = null; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + @"Source\Repos", + repositoryName); + } + else + { + path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + @"git", + repositoryName); + } + + if (!Directory.Exists(path)) + { + throw new DirectoryNotFoundException($"The directory '{path}' could not be found"); + } + + return path; + } + } +} diff --git a/src/NerdBank.GitVersioning.Benchmarks/Nerdbank.GitVersioning.Benchmarks.csproj b/src/NerdBank.GitVersioning.Benchmarks/Nerdbank.GitVersioning.Benchmarks.csproj new file mode 100644 index 00000000..d8e4b3c0 --- /dev/null +++ b/src/NerdBank.GitVersioning.Benchmarks/Nerdbank.GitVersioning.Benchmarks.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp3.1;netcoreapp2.1;net5.0 + $(TargetFrameworks);net461 + Exe + true + AnyCPU + false + + + + + + + + + + + + + diff --git a/src/NerdBank.GitVersioning.Benchmarks/Program.cs b/src/NerdBank.GitVersioning.Benchmarks/Program.cs new file mode 100644 index 00000000..90149bd6 --- /dev/null +++ b/src/NerdBank.GitVersioning.Benchmarks/Program.cs @@ -0,0 +1,11 @@ +using System; +using BenchmarkDotNet.Running; + +namespace Nerdbank.GitVersioning.Benchmarks +{ + class Program + { + static void Main(string[] args) => + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } +} diff --git a/src/NerdBank.GitVersioning.Tests/BuildIntegrationTests.cs b/src/NerdBank.GitVersioning.Tests/BuildIntegrationTests.cs index 3e7e89a7..b2ab89f5 100644 --- a/src/NerdBank.GitVersioning.Tests/BuildIntegrationTests.cs +++ b/src/NerdBank.GitVersioning.Tests/BuildIntegrationTests.cs @@ -23,7 +23,39 @@ using Xunit.Abstractions; using Version = System.Version; -public class BuildIntegrationTests : RepoTestBase, IClassFixture +[Trait("Engine", "Managed")] +[Collection("Build")] // msbuild sets current directory in the process, so we can't have it be concurrent with other build tests. +public class BuildIntegrationManagedTests : BuildIntegrationTests +{ + public BuildIntegrationManagedTests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: false); + + protected override void ApplyGlobalProperties(IDictionary globalProperties) + => globalProperties["NBGV_GitEngine"] = "Managed"; +} + +[Trait("Engine", "LibGit2")] +[Collection("Build")] // msbuild sets current directory in the process, so we can't have it be concurrent with other build tests. +public class BuildIntegrationLibGit2Tests : BuildIntegrationTests +{ + public BuildIntegrationLibGit2Tests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: true); + + protected override void ApplyGlobalProperties(IDictionary globalProperties) + => globalProperties["NBGV_GitEngine"] = "LibGit2"; +} + +public abstract class BuildIntegrationTests : RepoTestBase, IClassFixture { private const string GitVersioningTargetsFileName = "NerdBank.GitVersioning.targets"; private const string UnitTestCloudBuildPrefix = "UnitTest: "; @@ -32,6 +64,7 @@ public class BuildIntegrationTests : RepoTestBase, IClassFixture "APPVEYOR", "SYSTEM_", "BUILD_", + "NBGV_GitEngine", }; private BuildManager buildManager; private ProjectCollection projectCollection; @@ -57,10 +90,6 @@ public BuildIntegrationTests(ITestOutputHelper logger) private void Init() { -#if !NET461 - GitLoaderContext.RuntimePath = "./runtimes"; -#endif - int seed = (int)DateTime.Now.Ticks; this.random = new Random(seed); this.Logger.WriteLine("Random seed: {0}", seed); @@ -84,7 +113,9 @@ private void Init() } } - private string CommitIdShort => this.Repo.Head.Tip.Id.Sha.Substring(0, VersionOptions.DefaultGitCommitIdShortFixedLength); + private string CommitIdShort => this.Context.GitCommitId?.Substring(0, VersionOptions.DefaultGitCommitIdShortFixedLength); + + protected abstract void ApplyGlobalProperties(IDictionary globalProperties); protected override void Dispose(bool disposing) { @@ -190,7 +221,7 @@ public async Task GetBuildVersion_In_Git_But_Head_Lacks_VersionFile() this.WriteVersionFile("3.4"); Assumes.True(repo.Index[VersionFile.JsonFileName] == null); var buildResult = await this.BuildAsync(); - Assert.Equal("3.4.0." + GetIdAsVersion(repo, repo.Head.Tip).Revision, buildResult.BuildVersion); + Assert.Equal("3.4.0." + this.GetVersion().Revision, buildResult.BuildVersion); Assert.Equal("3.4.0+" + repo.Head.Tip.Id.Sha.Substring(0, VersionOptions.DefaultGitCommitIdShortFixedLength), buildResult.AssemblyInformationalVersion); } @@ -203,7 +234,7 @@ public async Task GetBuildVersion_In_Git_But_WorkingCopy_Has_Changes() this.WriteVersionFile(majorMinorVersion, prerelease); this.InitializeSourceControl(); var workingCopyVersion = VersionOptions.FromVersion(new Version("6.0")); - VersionFile.SetVersion(this.RepoPath, workingCopyVersion); + this.Context.VersionFile.SetVersion(this.RepoPath, workingCopyVersion); var buildResult = await this.BuildAsync(); this.AssertStandardProperties(workingCopyVersion, buildResult); } @@ -215,7 +246,7 @@ public async Task GetBuildVersion_In_Git_No_VersionFile_At_All() var repo = new Repository(this.RepoPath); // do not assign Repo property to avoid commits being generated later repo.Commit("empty", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); var buildResult = await this.BuildAsync(); - Assert.Equal("0.0.0." + GetIdAsVersion(repo, repo.Head.Tip).Revision, buildResult.BuildVersion); + Assert.Equal("0.0.0." + this.GetVersion().Revision, buildResult.BuildVersion); Assert.Equal("0.0.0+" + repo.Head.Tip.Id.Sha.Substring(0, VersionOptions.DefaultGitCommitIdShortFixedLength), buildResult.AssemblyInformationalVersion); } @@ -295,7 +326,7 @@ public async Task GetBuildVersion_StableRelease() var buildResult = await this.BuildAsync(); this.AssertStandardProperties(VersionOptions.FromVersion(new Version(majorMinorVersion)), buildResult); - Version version = this.GetIdAsVersion(this.Repo.Head.Tip); + Version version = this.GetVersion(); Assert.Equal($"{version.Major}.{version.Minor}.{buildResult.GitVersionHeight}", buildResult.NuGetPackageVersion); } @@ -422,7 +453,7 @@ public async Task GetBuildVersion_Minus1BuildOffset_NotYetCommitted() Version = new SemanticVersion(new Version(14, 1)), VersionHeightOffset = -1, }; - VersionFile.SetVersion(this.RepoPath, versionOptions); + this.Context.VersionFile.SetVersion(this.RepoPath, versionOptions); var buildResult = await this.BuildAsync(); this.AssertStandardProperties(versionOptions, buildResult); } @@ -573,6 +604,7 @@ public async Task CloudBuildVariables_SetInCI(IReadOnlyDictionary()); } + protected override GitContext CreateGitContext(string path, string committish = null) => throw new NotImplementedException(); + #if !NETCOREAPP /// /// Create a native resource .dll and verify that its version @@ -1002,9 +1036,9 @@ private static RestoreEnvironmentVariables ApplyEnvironmentVariables(IReadOnlyDi private void AssertStandardProperties(VersionOptions versionOptions, BuildResults buildResult, string relativeProjectDirectory = null) { int versionHeight = this.GetVersionHeight(relativeProjectDirectory); - Version idAsVersion = this.GetIdAsVersion(relativeProjectDirectory); + Version idAsVersion = this.GetVersion(relativeProjectDirectory); string commitIdShort = this.CommitIdShort; - Version version = this.GetIdAsVersion(relativeProjectDirectory); + Version version = this.GetVersion(relativeProjectDirectory); Version assemblyVersion = GetExpectedAssemblyVersion(versionOptions, version); var additionalBuildMetadata = from item in buildResult.BuildResult.ProjectStateAfterBuild.GetItems("BuildMetadata") select item.EvaluatedInclude; @@ -1028,8 +1062,8 @@ private void AssertStandardProperties(VersionOptions versionOptions, BuildResult Assert.Equal($"{idAsVersion.Major}.{idAsVersion.Minor}.{idAsVersion.Build}", buildResult.BuildVersion3Components); Assert.Equal(idAsVersion.Build.ToString(), buildResult.BuildVersionNumberComponent); Assert.Equal($"{idAsVersion.Major}.{idAsVersion.Minor}.{idAsVersion.Build}", buildResult.BuildVersionSimple); - Assert.Equal(this.Repo.Head.Tip.Id.Sha, buildResult.GitCommitId); - Assert.Equal(this.Repo.Head.Tip.Author.When.UtcTicks.ToString(CultureInfo.InvariantCulture), buildResult.GitCommitDateTicks); + Assert.Equal(this.LibGit2Repository.Head.Tip.Id.Sha, buildResult.GitCommitId); + Assert.Equal(this.LibGit2Repository.Head.Tip.Author.When.UtcTicks.ToString(CultureInfo.InvariantCulture), buildResult.GitCommitDateTicks); Assert.Equal(commitIdShort, buildResult.GitCommitIdShort); Assert.Equal(versionHeight.ToString(), buildResult.GitVersionHeight); Assert.Equal($"{version.Major}.{version.Minor}", buildResult.MajorMinorVersion); @@ -1097,6 +1131,7 @@ private async Task BuildAsync(string target = Targets.GetBuildVers var eventLogger = new MSBuildLogger { Verbosity = LoggerVerbosity.Minimal }; var loggers = new ILogger[] { eventLogger }; this.testProject.Save(); // persist generated project on disk for analysis + this.ApplyGlobalProperties(this.globalProperties); var buildResult = await this.buildManager.BuildAsync( this.Logger, this.projectCollection, diff --git a/src/NerdBank.GitVersioning.Tests/GitContextTests.cs b/src/NerdBank.GitVersioning.Tests/GitContextTests.cs new file mode 100644 index 00000000..05c4e8d9 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/GitContextTests.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using Nerdbank.GitVersioning; +using Xunit; +using Xunit.Abstractions; + +[Trait("Engine", "Managed")] +public class GitContextManagedTests : GitContextTests +{ + public GitContextManagedTests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: false); +} + +[Trait("Engine", "LibGit2")] +public class GitContextLibGit2Tests : GitContextTests +{ + public GitContextLibGit2Tests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: true); +} + +public abstract class GitContextTests : RepoTestBase +{ + protected GitContextTests(ITestOutputHelper logger) : base(logger) + { + this.InitializeSourceControl(); + this.AddCommits(); + } + + [Fact] + public void InitialDefaultState() + { + Assert.Equal(this.LibGit2Repository.Head.Tip.Id.Sha, this.Context.GitCommitId); + Assert.Equal(this.LibGit2Repository.Head.Tip.Author.When, this.Context.GitCommitDate); + Assert.Equal("refs/heads/master", this.Context.HeadCanonicalName); + Assert.Equal(this.RepoPath, this.Context.AbsoluteProjectDirectory); + Assert.Equal(this.RepoPath, this.Context.WorkingTreePath); + Assert.Equal(string.Empty, this.Context.RepoRelativeProjectDirectory); + Assert.True(this.Context.IsHead); + Assert.True(this.Context.IsRepository); + Assert.False(this.Context.IsShallow); + Assert.NotNull(this.Context.VersionFile); + } + + [Fact] + public void SelectHead() + { + Assert.True(this.Context.TrySelectCommit("HEAD")); + Assert.Equal(this.LibGit2Repository.Head.Tip.Sha, this.Context.GitCommitId); + } + + [Theory, CombinatorialData] + public void SelectCommitByFullId(bool uppercase) + { + Assert.True(this.Context.TrySelectCommit(uppercase ? this.Context.GitCommitId.ToUpperInvariant() : this.Context.GitCommitId)); + Assert.Equal(this.LibGit2Repository.Head.Tip.Sha, this.Context.GitCommitId); + } + + [Theory, CombinatorialData] + public void SelectCommitByPartialId(bool fromPack, bool oddLength) + { + if (fromPack) + { + this.LibGit2Repository.ObjectDatabase.Pack(new LibGit2Sharp.PackBuilderOptions(Path.Combine(this.RepoPath, ".git", "objects", "pack"))); + foreach (string obDirectory in Directory.EnumerateDirectories(Path.Combine(this.RepoPath, ".git", "objects"), "??")) + { + TestUtilities.DeleteDirectory(obDirectory); + } + + // The managed git context always assumes read-only access. It won't detect a new Git pack file being + // created on the fly, so we have to re-initialize. + this.Context = this.CreateGitContext(this.RepoPath, null); + } + + Assert.True(this.Context.TrySelectCommit(this.Context.GitCommitId.Substring(0, oddLength ? 11 : 12))); + Assert.Equal(this.LibGit2Repository.Head.Tip.Sha, this.Context.GitCommitId); + } + + [SkippableTheory] + [InlineData(4)] + [InlineData(7)] + [InlineData(8)] + [InlineData(11)] + public void GetShortUniqueCommitId(int length) + { + Skip.If(length < 7 && this.Context is Nerdbank.GitVersioning.LibGit2.LibGit2Context, "LibGit2Sharp never returns commit IDs with fewer than 7 characters."); + Assert.Equal(this.Context.GitCommitId.Substring(0, length), this.Context.GetShortUniqueCommitId(length)); + } + + [Theory, CombinatorialData] + public void SelectCommitByTag(bool packedRefs, bool canonicalName) + { + if (packedRefs) + { + File.WriteAllText(Path.Combine(this.RepoPath, ".git", "packed-refs"), $"# pack-refs with: peeled fully-peeled sorted \n{this.Context.GitCommitId} refs/tags/test\n"); + } + else + { + this.LibGit2Repository.Tags.Add("test", this.LibGit2Repository.Head.Tip); + } + + Assert.True(this.Context.TrySelectCommit(canonicalName ? "refs/tags/test" : "test")); + Assert.Equal(this.LibGit2Repository.Head.Tip.Sha, this.Context.GitCommitId); + } + + [Theory, CombinatorialData] + public void SelectCommitByBranch(bool packedRefs, bool canonicalName) + { + if (packedRefs) + { + File.WriteAllText(Path.Combine(this.RepoPath, ".git", "packed-refs"), $"# pack-refs with: peeled fully-peeled sorted \n{this.Context.GitCommitId} refs/heads/test\n"); + } + else + { + this.LibGit2Repository.Branches.Add("test", this.LibGit2Repository.Head.Tip); + } + + Assert.True(this.Context.TrySelectCommit(canonicalName ? "refs/heads/test" : "test")); + Assert.Equal(this.LibGit2Repository.Head.Tip.Sha, this.Context.GitCommitId); + } + + [Theory, CombinatorialData] + public void SelectCommitByRemoteBranch(bool packedRefs, bool canonicalName) + { + if (packedRefs) + { + File.WriteAllText(Path.Combine(this.RepoPath, ".git", "packed-refs"), $"# pack-refs with: peeled fully-peeled sorted \n{this.Context.GitCommitId} refs/remotes/origin/test\n"); + } + else + { + string fileName = Path.Combine(this.RepoPath, ".git", "refs", "remotes", "origin", "test"); + Directory.CreateDirectory(Path.GetDirectoryName(fileName)); + File.WriteAllText(fileName, $"{this.Context.GitCommitId}\n"); + } + + Assert.True(this.Context.TrySelectCommit(canonicalName ? "refs/remotes/origin/test" : "origin/test")); + Assert.Equal(this.LibGit2Repository.Head.Tip.Sha, this.Context.GitCommitId); + } + + [Fact] + public void SelectDirectory_Empty() + { + this.Context.RepoRelativeProjectDirectory = string.Empty; + Assert.Equal(string.Empty, this.Context.RepoRelativeProjectDirectory); + } + + [Fact] + public void SelectDirectory_SubDir() + { + string absolutePath = Path.Combine(this.RepoPath, "sub"); + Directory.CreateDirectory(absolutePath); + this.Context.RepoRelativeProjectDirectory = "sub"; + Assert.Equal("sub", this.Context.RepoRelativeProjectDirectory); + Assert.Equal(absolutePath, this.Context.AbsoluteProjectDirectory); + } +} diff --git a/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs b/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs index 7445a440..7a8704dc 100644 --- a/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs +++ b/src/NerdBank.GitVersioning.Tests/GitExtensionsTests.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; -using System.Text; -using System.Threading.Tasks; using LibGit2Sharp; using Nerdbank.GitVersioning; +using Nerdbank.GitVersioning.LibGit2; using Validation; using Xunit; using Xunit.Abstractions; @@ -19,50 +19,58 @@ public GitExtensionsTests(ITestOutputHelper Logger) this.InitializeSourceControl(); } + protected new LibGit2Context Context => (LibGit2Context)base.Context; + + protected override GitContext CreateGitContext(string path, string committish = null) => GitContext.Create(path, committish, writable: true); + [Fact] public void GetHeight_EmptyRepo() { - Branch head = this.Repo.Head; - Assert.Throws(() => head.GetHeight()); - Assert.Throws(() => head.GetHeight(c => true)); + this.InitializeSourceControl(); + + Branch head = this.LibGit2Repository.Head; + Assert.Throws(() => LibGit2GitExtensions.GetHeight(this.Context)); + Assert.Throws(() => LibGit2GitExtensions.GetHeight(this.Context, c => true)); } [Fact] public void GetHeight_SinglePath() { - var first = this.Repo.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - var second = this.Repo.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - var third = this.Repo.Commit("Third", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - Assert.Equal(3, this.Repo.Head.GetHeight()); - Assert.Equal(3, this.Repo.Head.GetHeight(c => true)); - - Assert.Equal(2, this.Repo.Head.GetHeight(c => c != first)); - Assert.Equal(1, this.Repo.Head.GetHeight(c => c != second)); + var first = this.LibGit2Repository.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var second = this.LibGit2Repository.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var third = this.LibGit2Repository.Commit("Third", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + this.SetContextToHead(); + Assert.Equal(3, LibGit2GitExtensions.GetHeight(this.Context)); + Assert.Equal(3, LibGit2GitExtensions.GetHeight(this.Context, c => true)); + + Assert.Equal(2, LibGit2GitExtensions.GetHeight(this.Context, c => c != first)); + Assert.Equal(1, LibGit2GitExtensions.GetHeight(this.Context, c => c != second)); } [Fact] public void GetHeight_Merge() { - var firstCommit = this.Repo.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - var anotherBranch = this.Repo.CreateBranch("another"); - var secondCommit = this.Repo.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - Commands.Checkout(this.Repo, anotherBranch); + var firstCommit = this.LibGit2Repository.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var anotherBranch = this.LibGit2Repository.CreateBranch("another"); + var secondCommit = this.LibGit2Repository.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + Commands.Checkout(this.LibGit2Repository, anotherBranch); Commit[] branchCommits = new Commit[5]; for (int i = 1; i <= branchCommits.Length; i++) { - branchCommits[i - 1] = this.Repo.Commit($"branch commit #{i}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + branchCommits[i - 1] = this.LibGit2Repository.Commit($"branch commit #{i}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); } - this.Repo.Merge(secondCommit, new Signature("t", "t@t.com", DateTimeOffset.Now), new MergeOptions { FastForwardStrategy = FastForwardStrategy.NoFastForward }); + this.LibGit2Repository.Merge(secondCommit, new Signature("t", "t@t.com", DateTimeOffset.Now), new MergeOptions { FastForwardStrategy = FastForwardStrategy.NoFastForward }); + this.SetContextToHead(); // While we've created 8 commits, the tallest height is only 7. - Assert.Equal(7, this.Repo.Head.GetHeight()); + Assert.Equal(7, LibGit2GitExtensions.GetHeight(this.Context)); // Now stop enumerating early on just one branch of the ancestry -- the number should remain high. - Assert.Equal(7, this.Repo.Head.GetHeight(c => c != secondCommit)); + Assert.Equal(7, LibGit2GitExtensions.GetHeight(this.Context, c => c != secondCommit)); // This time stop in both branches of history, and verify that we count the taller one. - Assert.Equal(3, this.Repo.Head.GetHeight(c => c != secondCommit && c != branchCommits[2])); + Assert.Equal(3, LibGit2GitExtensions.GetHeight(this.Context, c => c != secondCommit && c != branchCommits[2])); } [Fact] @@ -86,51 +94,52 @@ public void GetCommitsFromVersion_WithPathFilters() // Commit touching excluded path does not affect version height var ignoredFilePath = Path.Combine(this.RepoPath, relativeDirectory, "ignore.txt"); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - commitsAt121.Add(this.Repo.Commit("Add excluded file", this.Signer, this.Signer)); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + commitsAt121.Add(this.LibGit2Repository.Commit("Add excluded file", this.Signer, this.Signer)); // Commit touching both excluded and included path does affect height var includedFilePath = Path.Combine(this.RepoPath, relativeDirectory, "another-file.txt"); File.WriteAllText(includedFilePath, "hello"); File.WriteAllText(ignoredFilePath, "changed"); - Commands.Stage(this.Repo, includedFilePath); - Commands.Stage(this.Repo, ignoredFilePath); - commitsAt122.Add(this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer)); + Commands.Stage(this.LibGit2Repository, includedFilePath); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + commitsAt122.Add(this.LibGit2Repository.Commit("Change both excluded and included file", this.Signer, this.Signer)); // Commit touching excluded directory does not affect version height var fileInExcludedDirPath = Path.Combine(this.RepoPath, relativeDirectory, "excluded-dir", "ignore.txt"); Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); File.WriteAllText(fileInExcludedDirPath, "hello"); - Commands.Stage(this.Repo, fileInExcludedDirPath); - commitsAt122.Add(this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer)); + Commands.Stage(this.LibGit2Repository, fileInExcludedDirPath); + commitsAt122.Add(this.LibGit2Repository.Commit("Add file to excluded dir", this.Signer, this.Signer)); // Commit touching project directory affects version height File.WriteAllText(includedFilePath, "more changes"); - Commands.Stage(this.Repo, includedFilePath); - commitsAt123.Add(this.Repo.Commit("Changed included file", this.Signer, this.Signer)); + Commands.Stage(this.LibGit2Repository, includedFilePath); + commitsAt123.Add(this.LibGit2Repository.Commit("Changed included file", this.Signer, this.Signer)); + this.Context.RepoRelativeProjectDirectory = relativeDirectory; Assert.Equal( commitsAt121.OrderBy(c => c.Sha), - this.Repo.GetCommitsFromVersion(new Version(1, 2, 1), relativeDirectory).OrderBy(c => c.Sha)); + LibGit2GitExtensions.GetCommitsFromVersion(this.Context, new Version(1, 2, 1)).OrderBy(c => c.Sha)); Assert.Equal( commitsAt122.OrderBy(c => c.Sha), - this.Repo.GetCommitsFromVersion(new Version(1, 2, 2), relativeDirectory).OrderBy(c => c.Sha)); + LibGit2GitExtensions.GetCommitsFromVersion(this.Context, new Version(1, 2, 2)).OrderBy(c => c.Sha)); Assert.Equal( commitsAt123.OrderBy(c => c.Sha), - this.Repo.GetCommitsFromVersion(new Version(1, 2, 3), relativeDirectory).OrderBy(c => c.Sha)); + LibGit2GitExtensions.GetCommitsFromVersion(this.Context, new Version(1, 2, 3)).OrderBy(c => c.Sha)); } [Theory] - [InlineData("2.2-alpha", "2.2-rc", false)] - [InlineData("2.2-rc", "2.2", false)] - [InlineData("2.2", "2.3-alpha", true)] - [InlineData("2.2", "2.3", true)] - [InlineData("2.2-rc", "2.3", true)] - [InlineData("2.2-alpha.{height}", "2.2-rc.{height}", true)] - [InlineData("2.2-alpha.{height}", "2.3-rc.{height}", true)] - [InlineData("2.2-alpha.{height}", "2.2", true)] - [InlineData("2.2", "2.2-alpha.{height}", true)] - public void GetVersionHeight_ProgressAndReset(string version1, string version2, bool versionHeightReset) + [InlineData("2.2", "2.2-alpha.{height}", 1, 1, true)] + [InlineData("2.2", "2.3", 1, 1, true)] + [InlineData("2.2", "2.3-alpha", 1, 1, true)] + [InlineData("2.2-alpha", "2.2-rc", 1, 2, false)] + [InlineData("2.2-alpha.{height}", "2.2", 1, 1, true)] + [InlineData("2.2-alpha.{height}", "2.2-rc.{height}", 1, 1, true)] + [InlineData("2.2-alpha.{height}", "2.3-rc.{height}", 1, 1, true)] + [InlineData("2.2-rc", "2.2", 1, 2, false)] + [InlineData("2.2-rc", "2.3", 1, 1, true)] + public void GetVersionHeight_ProgressAndReset(string version1, string version2, int expectedHeight1, int expectedHeight2, bool versionHeightReset) { const string repoRelativeSubDirectory = "subdir"; @@ -145,19 +154,23 @@ public void GetVersionHeight_ProgressAndReset(string version1, string version2, repoRelativeSubDirectory); int height2 = this.GetVersionHeight(repoRelativeSubDirectory); - int height1 = this.GetVersionHeight(this.Repo.Head.Commits.Skip(1).First(), repoRelativeSubDirectory); + Debug.WriteLine("---"); + int height1 = this.GetVersionHeight(this.LibGit2Repository.Head.Commits.Skip(1).First().Sha, repoRelativeSubDirectory); this.Logger.WriteLine("Height 1: {0}", height1); this.Logger.WriteLine("Height 2: {0}", height2); + Assert.Equal(expectedHeight1, height1); + Assert.Equal(expectedHeight2, height2); + Assert.Equal(!versionHeightReset, height2 > height1); } [Fact] public void GetTruncatedCommitIdAsInteger_Roundtrip() { - var firstCommit = this.Repo.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - var secondCommit = this.Repo.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var firstCommit = this.LibGit2Repository.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var secondCommit = this.LibGit2Repository.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); int id1 = firstCommit.GetTruncatedCommitIdAsInt32(); int id2 = secondCommit.GetTruncatedCommitIdAsInt32(); @@ -165,17 +178,17 @@ public void GetTruncatedCommitIdAsInteger_Roundtrip() this.Logger.WriteLine($"Commit {firstCommit.Id.Sha.Substring(0, 8)} as int: {id1}"); this.Logger.WriteLine($"Commit {secondCommit.Id.Sha.Substring(0, 8)} as int: {id2}"); - Assert.Equal(firstCommit, this.Repo.GetCommitFromTruncatedIdInteger(id1)); - Assert.Equal(secondCommit, this.Repo.GetCommitFromTruncatedIdInteger(id2)); + Assert.Equal(firstCommit, this.LibGit2Repository.GetCommitFromTruncatedIdInteger(id1)); + Assert.Equal(secondCommit, this.LibGit2Repository.GetCommitFromTruncatedIdInteger(id2)); } [Fact] public void GetIdAsVersion_ReadsMajorMinorFromVersionTxt() { this.WriteVersionFile("4.8"); - var firstCommit = this.Repo.Commits.First(); + var firstCommit = this.LibGit2Repository.Commits.First(); - Version v1 = this.GetIdAsVersion(firstCommit); + Version v1 = this.GetVersion(committish: firstCommit.Sha); Assert.Equal(4, v1.Major); Assert.Equal(8, v1.Minor); } @@ -184,9 +197,9 @@ public void GetIdAsVersion_ReadsMajorMinorFromVersionTxt() public void GetIdAsVersion_ReadsMajorMinorFromVersionTxtInSubdirectory() { this.WriteVersionFile("4.8", relativeDirectory: "foo/bar"); - var firstCommit = this.Repo.Commits.First(); + var firstCommit = this.LibGit2Repository.Commits.First(); - Version v1 = this.GetIdAsVersion(firstCommit, "foo/bar"); + Version v1 = this.GetVersion("foo/bar", firstCommit.Sha); Assert.Equal(4, v1.Major); Assert.Equal(8, v1.Minor); } @@ -195,9 +208,9 @@ public void GetIdAsVersion_ReadsMajorMinorFromVersionTxtInSubdirectory() public void GetIdAsVersion_MissingVersionTxt() { this.AddCommits(); - var firstCommit = this.Repo.Commits.First(); + var firstCommit = this.LibGit2Repository.Commits.First(); - Version v1 = this.GetIdAsVersion(firstCommit); + Version v1 = this.GetVersion(committish: firstCommit.Sha); Assert.Equal(0, v1.Major); Assert.Equal(0, v1.Minor); } @@ -208,8 +221,8 @@ public void GetIdAsVersion_VersionFileNeverCheckedIn_3Ints() this.AddCommits(); var expectedVersion = new Version(1, 1, 0); var unstagedVersionData = VersionOptions.FromVersion(expectedVersion); - string versionFilePath = VersionFile.SetVersion(this.RepoPath, unstagedVersionData); - Version actualVersion = this.GetIdAsVersion(); + string versionFilePath = this.Context.VersionFile.SetVersion(this.RepoPath, unstagedVersionData); + Version actualVersion = this.GetVersion(); Assert.Equal(expectedVersion.Major, actualVersion.Major); Assert.Equal(expectedVersion.Minor, actualVersion.Minor); Assert.Equal(expectedVersion.Build, actualVersion.Build); @@ -225,45 +238,45 @@ public void GetIdAsVersion_VersionFileNeverCheckedIn_2Ints() this.AddCommits(); var expectedVersion = new Version(1, 1); var unstagedVersionData = VersionOptions.FromVersion(expectedVersion); - string versionFilePath = VersionFile.SetVersion(this.RepoPath, unstagedVersionData); - Version actualVersion = this.GetIdAsVersion(); + string versionFilePath = this.Context.VersionFile.SetVersion(this.RepoPath, unstagedVersionData); + Version actualVersion = this.GetVersion(); Assert.Equal(expectedVersion.Major, actualVersion.Major); Assert.Equal(expectedVersion.Minor, actualVersion.Minor); Assert.Equal(0, actualVersion.Build); // height is 0 since the change hasn't been committed. - Assert.Equal(this.Repo.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); + Assert.Equal(this.LibGit2Repository.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); } [Fact] public void GetIdAsVersion_VersionFileChangedOnDisk() { this.WriteVersionFile(); - var versionChangeCommit = this.Repo.Commits.First(); + var versionChangeCommit = this.LibGit2Repository.Commits.First(); this.AddCommits(); // Verify that we're seeing the original version. - Version actualVersion = this.GetIdAsVersion(); + Version actualVersion = this.GetVersion(); Assert.Equal(1, actualVersion.Major); Assert.Equal(2, actualVersion.Minor); Assert.Equal(2, actualVersion.Build); - Assert.Equal(this.Repo.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); + Assert.Equal(this.LibGit2Repository.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); // Now make a change on disk that isn't committed yet. - string versionFile = VersionFile.SetVersion(this.RepoPath, new Version("1.3")); + string versionFile = this.Context.VersionFile.SetVersion(this.RepoPath, new Version("1.3")); // Verify that HEAD reports whatever is on disk at the time. - actualVersion = this.GetIdAsVersion(); + actualVersion = this.GetVersion(); Assert.Equal(1, actualVersion.Major); Assert.Equal(3, actualVersion.Minor); Assert.Equal(0, actualVersion.Build); - Assert.Equal(this.Repo.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); + Assert.Equal(this.LibGit2Repository.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); // Now commit it and verify the height advances 0->1 this.CommitVersionFile(versionFile, "1.3"); - actualVersion = this.GetIdAsVersion(); + actualVersion = this.GetVersion(); Assert.Equal(1, actualVersion.Major); Assert.Equal(3, actualVersion.Minor); Assert.Equal(1, actualVersion.Build); - Assert.Equal(this.Repo.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); + Assert.Equal(this.LibGit2Repository.Head.Commits.First().GetTruncatedCommitIdAsUInt16(), actualVersion.Revision); } [Fact] @@ -303,14 +316,15 @@ public void GetIdAsVersion_Roundtrip(string version, string assemblyVersion, int Version[] versions = new Version[commits.Length]; for (int i = 0; i < commits.Length; i++) { - commits[i] = this.Repo.Commit($"Commit {i + 1}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - versions[i] = this.GetIdAsVersion(commits[i], repoRelativeSubDirectory); + commits[i] = this.LibGit2Repository.Commit($"Commit {i + 1}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + versions[i] = this.GetVersion(repoRelativeSubDirectory, commits[i].Sha); this.Logger.WriteLine($"Commit {commits[i].Id.Sha.Substring(0, 8)} as version: {versions[i]}"); } + this.Context.RepoRelativeProjectDirectory = repoRelativeSubDirectory; for (int i = 0; i < commits.Length; i++) { - Assert.Equal(commits[i], this.Repo.GetCommitFromVersion(versions[i], repoRelativeSubDirectory)); + Assert.Equal(commits[i], LibGit2GitExtensions.GetCommitFromVersion(this.Context, versions[i])); // Also verify that we can find it without the revision number. // This is important because stable, publicly released NuGet packages @@ -318,7 +332,7 @@ public void GetIdAsVersion_Roundtrip(string version, string assemblyVersion, int // But folks who specify a.b.c version numbers don't have any unique version component for the commit at all without the 4th integer. if (semanticVersion.Version.Build == -1) { - Assert.Equal(commits[i], this.Repo.GetCommitFromVersion(new Version(versions[i].Major, versions[i].Minor, versions[i].Build), repoRelativeSubDirectory)); + Assert.Equal(commits[i], LibGit2GitExtensions.GetCommitFromVersion(this.Context, new Version(versions[i].Major, versions[i].Minor, versions[i].Build))); } } } @@ -343,25 +357,25 @@ public void GetIdAsVersion_Roundtrip_UnstableOffset(int startingOffset, int offs { versionOptions.VersionHeightOffset += offsetStepChange; commits[i] = this.WriteVersionFile(versionOptions); - versions[i] = this.GetIdAsVersion(commits[i]); + versions[i] = this.GetVersion(committish: commits[i].Sha); - commits[i + 1] = this.Repo.Commit($"Commit {i + 1}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - versions[i + 1] = this.GetIdAsVersion(commits[i + 1]); + commits[i + 1] = this.LibGit2Repository.Commit($"Commit {i + 1}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + versions[i + 1] = this.GetVersion(committish: commits[i + 1].Sha); this.Logger.WriteLine($"Commit {commits[i].Id.Sha.Substring(0, 8)} as version: {versions[i]}"); this.Logger.WriteLine($"Commit {commits[i + 1].Id.Sha.Substring(0, 8)} as version: {versions[i + 1]}"); // Find the commits we just wrote while they are still at the tip of the branch. - var matchingCommits = this.Repo.GetCommitsFromVersion(versions[i]); + var matchingCommits = LibGit2GitExtensions.GetCommitsFromVersion(this.Context, versions[i]); Assert.Contains(commits[i], matchingCommits); - matchingCommits = this.Repo.GetCommitsFromVersion(versions[i + 1]); + matchingCommits = LibGit2GitExtensions.GetCommitsFromVersion(this.Context, versions[i + 1]); Assert.Contains(commits[i + 1], matchingCommits); } // Find all commits (again) now that history has been written. for (int i = 0; i < commits.Length; i++) { - var matchingCommits = this.Repo.GetCommitsFromVersion(versions[i]).ToList(); + var matchingCommits = LibGit2GitExtensions.GetCommitsFromVersion(this.Context, versions[i]).ToList(); Assert.Contains(commits[i], matchingCommits); if (!allowCollisions) { @@ -374,40 +388,42 @@ public void GetIdAsVersion_Roundtrip_UnstableOffset(int startingOffset, int offs public void GetIdAsVersion_Roundtrip_WithSubdirectoryVersionFiles() { var rootVersionExpected = VersionOptions.FromVersion(new Version(1, 0)); - VersionFile.SetVersion(this.RepoPath, rootVersionExpected); + this.Context.VersionFile.SetVersion(this.RepoPath, rootVersionExpected); var subPathVersionExpected = VersionOptions.FromVersion(new Version(1, 1)); const string subPathRelative = "a"; string subPath = Path.Combine(this.RepoPath, subPathRelative); Directory.CreateDirectory(subPath); - VersionFile.SetVersion(subPath, subPathVersionExpected); + this.Context.VersionFile.SetVersion(subPath, subPathVersionExpected); this.InitializeSourceControl(); - Commit head = this.Repo.Head.Commits.First(); - Version rootVersionActual = this.GetIdAsVersion(head); - Version subPathVersionActual = this.GetIdAsVersion(head, subPathRelative); + Commit head = this.LibGit2Repository.Head.Commits.First(); + Version rootVersionActual = this.GetVersion(committish: head.Sha); + Version subPathVersionActual = this.GetVersion(subPathRelative, head.Sha); // Verify that the versions calculated took the path into account. Assert.Equal(rootVersionExpected.Version.Version.Minor, rootVersionActual?.Minor); Assert.Equal(subPathVersionExpected.Version.Version.Minor, subPathVersionActual?.Minor); // Verify that we can find the commit given the version and path. - Assert.Equal(head, this.Repo.GetCommitFromVersion(rootVersionActual)); - Assert.Equal(head, this.Repo.GetCommitFromVersion(subPathVersionActual, subPathRelative)); + Assert.Equal(head, LibGit2GitExtensions.GetCommitFromVersion(this.Context, rootVersionActual)); + this.Context.RepoRelativeProjectDirectory = subPathRelative; + Assert.Equal(head, LibGit2GitExtensions.GetCommitFromVersion(this.Context, subPathVersionActual)); // Verify that mismatching path and version results in a null value. - Assert.Null(this.Repo.GetCommitFromVersion(rootVersionActual, subPathRelative)); - Assert.Null(this.Repo.GetCommitFromVersion(subPathVersionActual)); + Assert.Null(LibGit2GitExtensions.GetCommitFromVersion(this.Context, rootVersionActual)); + this.Context.RepoRelativeProjectDirectory = string.Empty; + Assert.Null(LibGit2GitExtensions.GetCommitFromVersion(this.Context, subPathVersionActual)); } [Fact] public void GetIdAsVersion_FitsInsideCompilerConstraints() { this.WriteVersionFile("2.5"); - var firstCommit = this.Repo.Commits.First(); + var firstCommit = this.LibGit2Repository.Commits.First(); - Version version = this.GetIdAsVersion(firstCommit); + Version version = this.GetVersion(committish: firstCommit.Sha); this.Logger.WriteLine(version.ToString()); // The C# compiler produces a build warning and truncates the version number if it exceeds 0xfffe, @@ -426,12 +442,12 @@ public void GetIdAsVersion_MigrationFromVersionTxtToJson() var jsonCommit = this.WriteVersionFile("4.8"); Assert.True(File.Exists(Path.Combine(this.RepoPath, "version.json"))); - Version v1 = this.GetIdAsVersion(txtCommit); + Version v1 = this.GetVersion(committish: txtCommit.Sha); Assert.Equal(4, v1.Major); Assert.Equal(8, v1.Minor); Assert.Equal(1, v1.Build); - Version v2 = this.GetIdAsVersion(jsonCommit); + Version v2 = this.GetVersion(committish: jsonCommit.Sha); Assert.Equal(4, v2.Major); Assert.Equal(8, v2.Minor); Assert.Equal(2, v2.Build); @@ -443,15 +459,14 @@ public void TestBiggerRepo() var testBiggerRepoPath = @"D:\git\NerdBank.GitVersioning"; Skip.If(!Directory.Exists(testBiggerRepoPath)); - using (this.Repo = new Repository(testBiggerRepoPath)) + using var largeRepo = new Repository(testBiggerRepoPath); + foreach (var commit in largeRepo.Head.Commits) { - foreach (var commit in this.Repo.Head.Commits) - { - var version = this.GetIdAsVersion(commit, "src"); - this.Logger.WriteLine($"commit {commit.Id} got version {version}"); - var backAgain = this.Repo.GetCommitFromVersion(version, "src"); - Assert.Equal(commit, backAgain); - } + var version = this.GetVersion("src", commit.Sha); + this.Logger.WriteLine($"commit {commit.Id} got version {version}"); + using var context = LibGit2Context.Create("src", commit.Sha); + var backAgain = LibGit2GitExtensions.GetCommitFromVersion(context, version); + Assert.Equal(commit, backAgain); } } @@ -459,10 +474,10 @@ private Commit[] CommitsWithVersion(string majorMinorVersion) { this.WriteVersionFile(majorMinorVersion); var commits = new Commit[2]; - commits[0] = this.Repo.Commits.First(); + commits[0] = this.LibGit2Repository.Commits.First(); for (int i = 1; i < commits.Length; i++) { - commits[i] = this.Repo.Commit($"Extra commit {i} for version {majorMinorVersion}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + commits[i] = this.LibGit2Repository.Commit($"Extra commit {i} for version {majorMinorVersion}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); } return commits; @@ -474,9 +489,9 @@ private void VerifyCommitsWithVersion(Commit[] commits) for (int i = 0; i < commits.Length; i++) { - Version encodedVersion = this.GetIdAsVersion(commits[i]); + Version encodedVersion = this.GetVersion(committish: commits[i].Sha); Assert.Equal(i + 1, encodedVersion.Build); - Assert.Equal(commits[i], this.Repo.GetCommitFromVersion(encodedVersion)); + Assert.Equal(commits[i], LibGit2GitExtensions.GetCommitFromVersion(this.Context, encodedVersion)); } } } diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/.gitattributes b/src/NerdBank.GitVersioning.Tests/ManagedGit/.gitattributes new file mode 100644 index 00000000..90f296af --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/.gitattributes @@ -0,0 +1,2 @@ +commit-* text eol=lf +tree-* text eol=lf \ No newline at end of file diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/3596ffe59898103a2675547d4597e742e1f2389c.gz b/src/NerdBank.GitVersioning.Tests/ManagedGit/3596ffe59898103a2675547d4597e742e1f2389c.gz new file mode 100644 index 00000000..cacb5b42 Binary files /dev/null and b/src/NerdBank.GitVersioning.Tests/ManagedGit/3596ffe59898103a2675547d4597e742e1f2389c.gz differ diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/DeltaStreamReaderTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/DeltaStreamReaderTests.cs new file mode 100644 index 00000000..2e475ca9 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/DeltaStreamReaderTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + // Test case borrowed from https://stefan.saasen.me/articles/git-clone-in-haskell-from-the-bottom-up/#format-of-the-delta-representation + public class DeltaStreamReaderTests + { + [Fact] + public void ReadCopyInstruction() + { + using (Stream stream = new MemoryStream( + new byte[] + { + 0b_10110000, + 0b_11010001, + 0b_00000001 + })) + { + var instruction = DeltaStreamReader.Read(stream).Value; + + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(0, instruction.Offset); + Assert.Equal(465, instruction.Size); + } + } + + [Fact] + public void ReadCopyInstruction_Memory() + { + var stream = new byte[] + { + 0b_10110000, + 0b_11010001, + 0b_00000001 + }; + var memory = new ReadOnlyMemory(stream); + + var instruction = DeltaStreamReader.Read(ref memory).Value; + + Assert.Equal(0, memory.Length); + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(0, instruction.Offset); + Assert.Equal(465, instruction.Size); + } + + [Fact] + public void ReadInsertInstruction() + { + using (Stream stream = new MemoryStream(new byte[] { 0b_00010111 })) + { + var instruction = DeltaStreamReader.Read(stream).Value; + + Assert.Equal(DeltaInstructionType.Insert, instruction.InstructionType); + Assert.Equal(0, instruction.Offset); + Assert.Equal(23, instruction.Size); + } + } + + [Fact] + public void ReadInsertInstruction_Memory() + { + var stream = new byte[] { 0b_00010111 }; + var memory = new ReadOnlyMemory(stream); + + var instruction = DeltaStreamReader.Read(ref memory).Value; + + Assert.Equal(0, memory.Length); + Assert.Equal(DeltaInstructionType.Insert, instruction.InstructionType); + Assert.Equal(0, instruction.Offset); + Assert.Equal(23, instruction.Size); + } + + [Fact] + public void ReadStreamTest() + { + using (Stream stream = new MemoryStream( + new byte[] + { + 0b_10110011, 0b_11001110, 0b_00000001, 0b_00100111, 0b_00000001, + 0b_10110011, 0b_01011111, 0b_00000011, 0b_01101100, 0b_00010000, 0b_10010011, + 0b_11110101, 0b_00000010, 0b_01101011, 0b_10110011, 0b_11001011, 0b_00010011, + 0b_01000110, 0b_00000011})) + { + Collection instructions = new Collection(); + + DeltaInstruction? current; + + while ((current = DeltaStreamReader.Read(stream)) != null) + { + instructions.Add(current.Value); + } + + Assert.Collection( + instructions, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(462, instruction.Offset); + Assert.Equal(295, instruction.Size); + }, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(863, instruction.Offset); + Assert.Equal(4204, instruction.Size); + }, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(757, instruction.Offset); + Assert.Equal(107, instruction.Size); + }, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(5067, instruction.Offset); + Assert.Equal(838, instruction.Size); + }); + } + } + + [Fact] + public void ReadStreamTest_Memory() + { + var stream = + new byte[] + { + 0b_10110011, 0b_11001110, 0b_00000001, 0b_00100111, 0b_00000001, + 0b_10110011, 0b_01011111, 0b_00000011, 0b_01101100, 0b_00010000, 0b_10010011, + 0b_11110101, 0b_00000010, 0b_01101011, 0b_10110011, 0b_11001011, 0b_00010011, + 0b_01000110, 0b_00000011}; + var memory = new ReadOnlyMemory(stream); + + Collection instructions = new Collection(); + + DeltaInstruction? current; + + while ((current = DeltaStreamReader.Read(ref memory)) != null) + { + instructions.Add(current.Value); + } + + Assert.Equal(0, memory.Length); + Assert.Collection( + instructions, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(462, instruction.Offset); + Assert.Equal(295, instruction.Size); + }, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(863, instruction.Offset); + Assert.Equal(4204, instruction.Size); + }, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(757, instruction.Offset); + Assert.Equal(107, instruction.Size); + }, + instruction => + { + Assert.Equal(DeltaInstructionType.Copy, instruction.InstructionType); + Assert.Equal(5067, instruction.Offset); + Assert.Equal(838, instruction.Size); + }); + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitCommitReaderTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitCommitReaderTests.cs new file mode 100644 index 00000000..c8f068c1 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitCommitReaderTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitCommitReaderTests + { + [Fact] + public void ReadTest() + { + using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\commit-d56dc3ed179053abef2097d1120b4507769bcf1a")) + { + var commit = GitCommitReader.Read(stream, GitObjectId.Parse("d56dc3ed179053abef2097d1120b4507769bcf1a"), readAuthor: true); + + Assert.Equal("d56dc3ed179053abef2097d1120b4507769bcf1a", commit.Sha.ToString()); + Assert.Equal("f914b48023c7c804a4f3be780d451f31aef74ac1", commit.Tree.ToString()); + + Assert.Collection( + commit.Parents, + c => Assert.Equal("4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6", c.ToString()), + c => Assert.Equal("0989e8fe0cd0e0900173b26decdfb24bc0cc8232", c.ToString())); + + var author = commit.Author.Value; + + Assert.Equal("Andrew Arnott", author.Name); + Assert.Equal(new DateTimeOffset(2020, 10, 6, 13, 40, 09, TimeSpan.FromHours(-6)), author.Date); + Assert.Equal("andrewarnott@gmail.com", author.Email); + + // Committer and commit message are not read + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitCommitTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitCommitTests.cs new file mode 100644 index 00000000..36aebaeb --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitCommitTests.cs @@ -0,0 +1,98 @@ +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitCommitTests + { + private readonly byte[] shaAsByteArray = new byte[] { 0x4e, 0x91, 0x27, 0x36, 0xc2, 0x7e, 0x40, 0xb3, 0x89, 0x90, 0x4d, 0x04, 0x6d, 0xc6, 0x3d, 0xc9, 0xf5, 0x78, 0x11, 0x7f }; + + [Fact] + public void EqualsObjectTest() + { + var commit = new GitCommit() + { + Sha = GitObjectId.Parse(this.shaAsByteArray), + }; + + var commit2 = new GitCommit() + { + Sha = GitObjectId.Parse(this.shaAsByteArray), + }; + + var emptyCommit = new GitCommit() + { + Sha = GitObjectId.Empty, + }; + + // Must be equal to itself + Assert.True(commit.Equals((object)commit)); + Assert.True(commit.Equals((object)commit2)); + + // Not equal to null + Assert.False(commit.Equals(null)); + + // Not equal to other representations of the commit + Assert.False(commit.Equals(this.shaAsByteArray)); + Assert.False(commit.Equals(commit.Sha)); + + // Not equal to other object ids + Assert.False(commit.Equals((object)emptyCommit)); + } + + [Fact] + public void EqualsCommitTest() + { + var commit = new GitCommit() + { + Sha = GitObjectId.Parse(this.shaAsByteArray), + }; + + var commit2 = new GitCommit() + { + Sha = GitObjectId.Parse(this.shaAsByteArray), + }; + + var emptyCommit = new GitCommit() + { + Sha = GitObjectId.Empty, + }; + + // Must be equal to itself + Assert.True(commit.Equals(commit2)); + Assert.True(commit.Equals(commit2)); + + // Not equal to other object ids + Assert.False(commit.Equals(emptyCommit)); + } + + [Fact] + public void GetHashCodeTest() + { + var commit = new GitCommit() + { + Sha = GitObjectId.Parse(this.shaAsByteArray), + }; + + var emptyCommit = new GitCommit() + { + Sha = GitObjectId.Empty, + }; + + // The hash code is the int32 representation of the first 4 bytes of the SHA hash + Assert.Equal(0x3627914e, commit.GetHashCode()); + Assert.Equal(0, emptyCommit.GetHashCode()); + } + + [Fact] + public void ToStringTest() + { + var commit = new GitCommit() + { + Sha = GitObjectId.Parse(this.shaAsByteArray), + }; + + Assert.Equal("Git Commit: 4e912736c27e40b389904d046dc63dc9f578117f", commit.ToString()); + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitObjectIdTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitObjectIdTests.cs new file mode 100644 index 00000000..c06d8f67 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitObjectIdTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; +using Xunit.Abstractions; + +namespace ManagedGit +{ + public class GitObjectIdTests + { + private readonly byte[] shaAsByteArray = new byte[] { 0x4e, 0x91, 0x27, 0x36, 0xc2, 0x7e, 0x40, 0xb3, 0x89, 0x90, 0x4d, 0x04, 0x6d, 0xc6, 0x3d, 0xc9, 0xf5, 0x78, 0x11, 0x7f }; + private const string shaAsHexString = "4e912736c27e40b389904d046dc63dc9f578117f"; + private readonly byte[] shaAsHexAsciiByteArray = Encoding.ASCII.GetBytes(shaAsHexString); + + [Fact] + public void ParseByteArrayTest() + { + var objectId = GitObjectId.Parse(this.shaAsByteArray); + + Span value = stackalloc byte[20]; + objectId.CopyTo(value); + Assert.True(value.SequenceEqual(this.shaAsByteArray.AsSpan())); + } + + [Fact] + public void ParseStringTest() + { + var objectId = GitObjectId.Parse(shaAsHexString); + + Span value = stackalloc byte[20]; + objectId.CopyTo(value); + Assert.True(value.SequenceEqual(this.shaAsByteArray.AsSpan())); + } + + [Fact] + public void ParseHexArrayTest() + { + var objectId = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + + Span value = stackalloc byte[20]; + objectId.CopyTo(value); + Assert.True(value.SequenceEqual(this.shaAsByteArray.AsSpan())); + } + + [Fact] + public void EqualsObjectTest() + { + var objectId = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + var objectId2 = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + + // Must be equal to itself + Assert.True(objectId.Equals((object)objectId)); + Assert.True(objectId.Equals((object)objectId2)); + + // Not equal to null + Assert.False(objectId.Equals(null)); + + // Not equal to other representations of the object id + Assert.False(objectId.Equals(this.shaAsHexAsciiByteArray)); + Assert.False(objectId.Equals(this.shaAsByteArray)); + Assert.False(objectId.Equals(shaAsHexString)); + + // Not equal to other object ids + Assert.False(objectId.Equals((object)GitObjectId.Empty)); + } + + [Fact] + public void EqualsObjectIdTest() + { + var objectId = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + var objectId2 = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + + // Must be equal to itself + Assert.True(objectId.Equals(objectId)); + Assert.True(objectId.Equals(objectId2)); + + // Not equal to other object ids + Assert.False(objectId.Equals(GitObjectId.Empty)); + } + + [Fact] + public void GetHashCodeTest() + { + // The hash code is the int32 representation of the first 4 bytes + var objectId = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + Assert.Equal(0x3627914e, objectId.GetHashCode()); + Assert.Equal(0, GitObjectId.Empty.GetHashCode()); + } + + [Fact] + public void AsUInt16Test() + { + // The hash code is the int32 representation of the first 4 bytes + var objectId = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + Assert.Equal(0x914e, objectId.AsUInt16()); + Assert.Equal(0, GitObjectId.Empty.GetHashCode()); + } + + [Fact] + public void ToStringTest() + { + var objectId = GitObjectId.Parse(this.shaAsByteArray); + Assert.Equal(shaAsHexString, objectId.ToString()); + } + + [Fact] + public void CopyToUtf16StringTest() + { + // Common use case: create the path to the object in the Git object store, + // e.g. git/objects/[byte 0]/[bytes 1 - 19] + byte[] valueAsBytes = Encoding.Unicode.GetBytes("git/objects/00/01020304050607080910111213141516171819"); + Span valueAsChars = MemoryMarshal.Cast(valueAsBytes); + + var objectId = GitObjectId.ParseHex(this.shaAsHexAsciiByteArray); + objectId.CopyAsHex(0, 1, valueAsChars.Slice(12, 1 * 2)); + objectId.CopyAsHex(1, 19, valueAsChars.Slice(15, 19 * 2)); + + var path = Encoding.Unicode.GetString(valueAsBytes); + Assert.Equal("git/objects/4e/912736c27e40b389904d046dc63dc9f578117f", path); + } + + [Fact] + public void CopyToTest() + { + var objectId = GitObjectId.Parse(this.shaAsByteArray); + + byte[] actual = new byte[20]; + objectId.CopyTo(actual); + + Assert.Equal(this.shaAsByteArray, actual); + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitObjectStreamTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitObjectStreamTests.cs new file mode 100644 index 00000000..cd525257 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitObjectStreamTests.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitObjectStreamTests + { + [Fact] + public void ReadTest() + { + using (Stream rawStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\3596ffe59898103a2675547d4597e742e1f2389c.gz")) + using (GitObjectStream stream = new GitObjectStream(rawStream, "commit")) + using (var sha = SHA1.Create()) + { + Assert.Equal(137, stream.Length); + var deflateStream = Assert.IsType(stream.BaseStream); + Assert.Same(rawStream, deflateStream.BaseStream); + Assert.Equal("commit", stream.ObjectType); + Assert.Equal(0, stream.Position); + + var hash = sha.ComputeHash(stream); + Assert.Equal("U1WYLbBP+xD47Y32m+hpCCTpnLA=", Convert.ToBase64String(hash)); + + Assert.Equal(stream.Length, stream.Position); + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackDeltafiedStreamTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackDeltafiedStreamTests.cs new file mode 100644 index 00000000..70728cfd --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackDeltafiedStreamTests.cs @@ -0,0 +1,40 @@ +using System.IO; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitPackDeltafiedStreamTests + { + // Reconstructs an object by reading the base stream and the delta stream. + // You can create delta representations of an object by running the + // test tool which is located in the t/helper/ folder of the Git source repository. + // Use with the delta -d [base file,in] [updated file,in] [delta file,out] arguments. + [Theory] + [InlineData(@"ManagedGit\commit-4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6", @"ManagedGit\commit.delta", @"ManagedGit\commit-d56dc3ed179053abef2097d1120b4507769bcf1a")] + [InlineData(@"ManagedGit\tree-bb36cf0ca445ccc8e5ce9cc88f7cf74128e96dc9", @"ManagedGit\tree.delta", @"ManagedGit\tree-f914b48023c7c804a4f3be780d451f31aef74ac1")] + public void TestDeltaStream(string basePath, string deltaPath, string expectedPath) + { + byte[] expected = null; + + using (Stream expectedStream = TestUtilities.GetEmbeddedResource(expectedPath)) + { + expected = new byte[expectedStream.Length]; + expectedStream.Read(expected); + } + + byte[] actual = new byte[expected.Length]; + + using (Stream baseStream = TestUtilities.GetEmbeddedResource(basePath)) + using (Stream deltaStream = TestUtilities.GetEmbeddedResource(deltaPath)) + using (GitPackDeltafiedStream deltafiedStream = new GitPackDeltafiedStream(baseStream, deltaStream)) + { + // Assert.Equal(expected.Length, deltafiedStream.Length); + + deltafiedStream.Read(actual); + + Assert.Equal(expected, actual); + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackIndexMappedReaderTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackIndexMappedReaderTests.cs new file mode 100644 index 00000000..153839b7 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackIndexMappedReaderTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitPackIndexMappedReaderTests + { + [Fact] + public void ConstructorNullTest() + { + Assert.Throws(() => new GitPackIndexMappedReader(null)); + } + + [Fact] + public void GetOffsetTest() + { + var indexFile = Path.GetTempFileName(); + + using (Stream resourceStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx")) + using (FileStream stream = File.Open(indexFile, FileMode.Open)) + { + resourceStream.CopyTo(stream); + } + + using (FileStream stream = File.OpenRead(indexFile)) + using (GitPackIndexReader reader = new GitPackIndexMappedReader(stream)) + { + // Offset of an object which is present + Assert.Equal(12, reader.GetOffset(GitObjectId.Parse("f5b401f40ad83f13030e946c9ea22cb54cb853cd"))); + Assert.Equal(317, reader.GetOffset(GitObjectId.Parse("d6781552a0a94adbf73ed77696712084754dc274"))); + + // null for an object which is not present + Assert.Null(reader.GetOffset(GitObjectId.Empty)); + } + + try + { + File.Delete(indexFile); + } + catch (UnauthorizedAccessException) + { + // TBD: Figure out what's keeping a lock on the file. Seems to be unique to Windows. + } + + } + + [Fact] + public void GetOffsetFromPartialTest() + { + var indexFile = Path.GetTempFileName(); + + using (Stream resourceStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx")) + using (FileStream stream = File.Open(indexFile, FileMode.Open)) + { + resourceStream.CopyTo(stream); + } + + using (FileStream stream = File.OpenRead(indexFile)) + using (var reader = new GitPackIndexMappedReader(stream)) + { + // Offset of an object which is present + (var offset, var objectId) = reader.GetOffset(new byte[] { 0xf5, 0xb4, 0x01, 0xf4 }); + Assert.Equal(12, offset); + Assert.Equal(GitObjectId.Parse("f5b401f40ad83f13030e946c9ea22cb54cb853cd"), objectId); + + (offset, objectId) = reader.GetOffset(new byte[] { 0xd6, 0x78, 0x15, 0x52 }); + Assert.Equal(317, offset); + Assert.Equal(GitObjectId.Parse("d6781552a0a94adbf73ed77696712084754dc274"), objectId); + + // null for an object which is not present + (offset, objectId) = reader.GetOffset(new byte[] { 0x00, 0x00, 0x00, 0x00 }); + Assert.Null(offset); + Assert.Null(objectId); + } + + try + { + File.Delete(indexFile); + } + catch (UnauthorizedAccessException) + { + // TBD: Figure out what's keeping a lock on the file. Seems to be unique to Windows. + } + + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackTests.cs new file mode 100644 index 00000000..94905c70 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitPackTests.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using Nerdbank.GitVersioning; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitPackTests : IDisposable + { + private readonly string indexFile = Path.GetTempFileName(); + private readonly string packFile = Path.GetTempFileName(); + + public GitPackTests() + { + using (Stream resourceStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx")) + using (FileStream stream = File.Open(this.indexFile, FileMode.Open)) + { + resourceStream.CopyTo(stream); + } + + using (Stream resourceStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.pack")) + using (FileStream stream = File.Open(this.packFile, FileMode.Open)) + { + resourceStream.CopyTo(stream); + } + } + + public void Dispose() + { + try + { + File.Delete(this.indexFile); + } + catch (UnauthorizedAccessException) + { + // TBD: Figure out what's keeping a lock on the file. Seems to be unique to Windows. + } + + try + { + File.Delete(this.packFile); + } + catch (UnauthorizedAccessException) + { + // TBD: Figure out what's keeping a lock on the file. Seems to be unique to Windows. + } + } + + [Fact] + public void GetPackedObject() + { + using (var gitPack = new GitPack( + (sha, objectType) => null, + new Lazy(() => File.OpenRead(this.indexFile)), + () => File.OpenRead(this.packFile), + GitPackNullCache.Instance)) + using (Stream commitStream = gitPack.GetObject(12, "commit")) + using (SHA1 sha = SHA1.Create()) + { + // This commit is not deltafied. It is stored as a .gz-compressed stream in the pack file. + var zlibStream = Assert.IsType(commitStream); + var deflateStream = Assert.IsType(zlibStream.BaseStream); + var pooledStream = Assert.IsType(deflateStream.BaseStream); + + Assert.Equal(222, commitStream.Length); + Assert.Equal("/zgldANj+jvgOwlecnOKylZDVQg=", Convert.ToBase64String(sha.ComputeHash(commitStream))); + } + } + + [Fact] + public void GetDeltafiedObject() + { + using (var gitPack = new GitPack( + (sha, objectType) => null, + new Lazy(() => File.OpenRead(this.indexFile)), + () => File.OpenRead(this.packFile), + GitPackNullCache.Instance)) + using (Stream commitStream = gitPack.GetObject(317, "commit")) + using (SHA1 sha = SHA1.Create()) + { + // This commit is not deltafied. It is stored as a .gz-compressed stream in the pack file. + var deltaStream = Assert.IsType(commitStream); + var zlibStream = Assert.IsType(deltaStream.BaseStream); + var deflateStream = Assert.IsType(zlibStream.BaseStream); + var pooledStream = Assert.IsType(deflateStream.BaseStream); + + Assert.Equal(137, commitStream.Length); + Assert.Equal("lZu/7nGb0n1UuO9SlPluFnSvj4o=", Convert.ToBase64String(sha.ComputeHash(commitStream))); + } + } + + [Fact] + public void GetInvalidObject() + { + using (var gitPack = new GitPack( + (sha, objectType) => null, + new Lazy(() => File.OpenRead(this.indexFile)), + () => File.OpenRead(this.packFile), + GitPackNullCache.Instance)) + { + Assert.Throws(() => gitPack.GetObject(12, "invalid")); + Assert.Throws(() => gitPack.GetObject(-1, "commit")); + Assert.Throws(() => gitPack.GetObject(1, "commit")); + Assert.Throws(() => gitPack.GetObject(2, "commit")); + Assert.Throws(() => gitPack.GetObject(int.MaxValue, "commit")); + } + } + + [Fact] + public void TryGetObjectTest() + { + using (var gitPack = new GitPack( + (sha, objectType) => null, + new Lazy(() => File.OpenRead(this.indexFile)), + () => File.OpenRead(this.packFile), + GitPackNullCache.Instance)) + using (SHA1 sha = SHA1.Create()) + { + Assert.True(gitPack.TryGetObject(GitObjectId.Parse("f5b401f40ad83f13030e946c9ea22cb54cb853cd"), "commit", out Stream commitStream)); + + // This commit is not deltafied. It is stored as a .gz-compressed stream in the pack file. + var zlibStream = Assert.IsType(commitStream); + var deflateStream = Assert.IsType(zlibStream.BaseStream); + var pooledStream = Assert.IsType(deflateStream.BaseStream); + + Assert.Equal(222, commitStream.Length); + Assert.Equal("/zgldANj+jvgOwlecnOKylZDVQg=", Convert.ToBase64String(sha.ComputeHash(commitStream))); + } + } + + [Fact] + public void TryGetMissingObjectTest() + { + using (var gitPack = new GitPack( + (sha, objectType) => null, + new Lazy(() => File.OpenRead(this.indexFile)), + () => File.OpenRead(this.packFile), + GitPackNullCache.Instance)) + { + Assert.False(gitPack.TryGetObject(GitObjectId.Empty, "commit", out Stream commitStream)); + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitRepositoryTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitRepositoryTests.cs new file mode 100644 index 00000000..fb8a3ba0 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitRepositoryTests.cs @@ -0,0 +1,296 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using LibGit2Sharp; +using Nerdbank.GitVersioning; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; +using Xunit.Abstractions; + +namespace ManagedGit +{ + public class GitRepositoryTests : RepoTestBase + { + public GitRepositoryTests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override Nerdbank.GitVersioning.GitContext CreateGitContext(string path, string committish = null) + => Nerdbank.GitVersioning.GitContext.Create(path, committish, writable: false); + + [Fact] + public void CreateTest() + { + this.InitializeSourceControl(); + this.AddCommits(1); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + AssertPath(Path.Combine(this.RepoPath, ".git"), repository.CommonDirectory); + AssertPath(Path.Combine(this.RepoPath, ".git"), repository.GitDirectory); + AssertPath(this.RepoPath, repository.WorkingDirectory); + AssertPath(Path.Combine(this.RepoPath, ".git", "objects"), repository.ObjectDirectory); + } + } + + [Fact] + public void CreateWorkTreeTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + string workTreePath = this.CreateDirectoryForNewRepo(); + Directory.Delete(workTreePath); + this.LibGit2Repository.Worktrees.Add("HEAD~1", "myworktree", workTreePath, isLocked: false); + + using (var repository = GitRepository.Create(workTreePath)) + { + AssertPath(Path.Combine(this.RepoPath, ".git"), repository.CommonDirectory); + AssertPath(Path.Combine(this.RepoPath, ".git", "worktrees", "myworktree"), repository.GitDirectory); + AssertPath(workTreePath, repository.WorkingDirectory); + AssertPath(Path.Combine(this.RepoPath, ".git", "objects"), repository.ObjectDirectory); + } + } + + [Fact] + public void CreateNotARepoTest() + { + Assert.Null(GitRepository.Create(null)); + Assert.Null(GitRepository.Create("")); + Assert.Null(GitRepository.Create("/A/Path/To/A/Directory/Which/Does/Not/Exist")); + Assert.Null(GitRepository.Create(this.RepoPath)); + } + + // A "normal" repository, where a branch is currently checked out. + [Fact] + public void GetHeadAsReferenceTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var headObjectId = GitObjectId.Parse(this.LibGit2Repository.Head.Tip.Sha); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var head = repository.GetHeadAsReferenceOrSha(); + var reference = Assert.IsType(head); + Assert.Equal("refs/heads/master", reference); + + Assert.Equal(headObjectId, repository.GetHeadCommitSha()); + + var headCommit = repository.GetHeadCommit(); + Assert.NotNull(headCommit); + Assert.Equal(headObjectId, headCommit.Value.Sha); + } + } + + // A repository with a detached HEAD. + [Fact] + public void GetHeadAsShaTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var newHead = this.LibGit2Repository.Head.Tip.Parents.Single(); + var newHeadObjectId = GitObjectId.Parse(newHead.Sha); + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.Head.Tip.Parents.Single()); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var detachedHead = repository.GetHeadAsReferenceOrSha(); + var reference = Assert.IsType(detachedHead); + Assert.Equal(newHeadObjectId, reference); + + Assert.Equal(newHeadObjectId, repository.GetHeadCommitSha()); + + var headCommit = repository.GetHeadCommit(); + Assert.NotNull(headCommit); + Assert.Equal(newHeadObjectId, headCommit.Value.Sha); + } + } + + // A fresh repository with no commits yet. + [Fact] + public void GetHeadMissingTest() + { + this.InitializeSourceControl(withInitialCommit: false); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var head = repository.GetHeadAsReferenceOrSha(); + var reference = Assert.IsType(head); + Assert.Equal("refs/heads/master", reference); + + Assert.Equal(GitObjectId.Empty, repository.GetHeadCommitSha()); + + Assert.Null(repository.GetHeadCommit()); + } + } + + // Fetch a commit from the object store + [Fact] + public void GetCommitTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var headObjectId = GitObjectId.Parse(this.LibGit2Repository.Head.Tip.Sha); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var commit = repository.GetCommit(headObjectId); + Assert.Equal(headObjectId, commit.Sha); + } + } + + [Fact] + public void GetInvalidCommitTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var headObjectId = GitObjectId.Parse(this.LibGit2Repository.Head.Tip.Sha); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + Assert.Throws(() => repository.GetCommit(GitObjectId.Empty)); + } + } + + [Fact] + public void GetTreeEntryTest() + { + this.InitializeSourceControl(); + File.WriteAllText(Path.Combine(this.RepoPath, "hello.txt"), "Hello, World"); + Commands.Stage(this.LibGit2Repository, "hello.txt"); + this.AddCommits(); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var headCommit = repository.GetHeadCommit(); + Assert.NotNull(headCommit); + + var helloBlobId = repository.GetTreeEntry(headCommit.Value.Tree, Encoding.UTF8.GetBytes("hello.txt")); + Assert.Equal("1856e9be02756984c385482a07e42f42efd5d2f3", helloBlobId.ToString()); + } + } + + [Fact] + public void GetInvalidTreeEntryTest() + { + this.InitializeSourceControl(); + File.WriteAllText(Path.Combine(this.RepoPath, "hello.txt"), "Hello, World"); + Commands.Stage(this.LibGit2Repository, "hello.txt"); + this.AddCommits(); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var headCommit = repository.GetHeadCommit(); + Assert.NotNull(headCommit); + + Assert.Equal(GitObjectId.Empty, repository.GetTreeEntry(headCommit.Value.Tree, Encoding.UTF8.GetBytes("goodbye.txt"))); + } + } + + [Fact] + public void GetObjectByShaTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var headObjectId = GitObjectId.Parse(this.LibGit2Repository.Head.Tip.Sha); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + var commitStream = repository.GetObjectBySha(headObjectId, "commit"); + Assert.NotNull(commitStream); + + var objectStream = Assert.IsType(commitStream); + Assert.Equal("commit", objectStream.ObjectType); + Assert.Equal(186, objectStream.Length); + } + } + + // This test runs on netcoreapp only; netstandard/netfx don't support Path.GetRelativePath +#if NETCOREAPP + [Fact] + public void GetObjectFromAlternateTest() + { + // Add 2 alternates for this repository, each with their own commit. + // Make sure that commits from the current repository and the alternates + // can be found. + // + // Alternate1 Alternate2 + // | | + // +-----+ +-----+ + // | + // Repo + this.InitializeSourceControl(); + + var localCommit = this.LibGit2Repository.Commit("Local", this.Signer, this.Signer, new CommitOptions() { AllowEmptyCommit = true }); + + var alternate1Path = this.CreateDirectoryForNewRepo(); + this.InitializeSourceControl(alternate1Path).Dispose(); + var alternate1 = new Repository(alternate1Path); + var alternate1Commit = alternate1.Commit("Alternate 1", this.Signer, this.Signer, new CommitOptions() { AllowEmptyCommit = true }); + + var alternate2Path = this.CreateDirectoryForNewRepo(); + this.InitializeSourceControl(alternate2Path).Dispose(); + var alternate2 = new Repository(alternate2Path); + var alternate2Commit = alternate2.Commit("Alternate 2", this.Signer, this.Signer, new CommitOptions() { AllowEmptyCommit = true }); + + var objectDatabasePath = Path.Combine(this.RepoPath, ".git", "objects"); + + Directory.CreateDirectory(Path.Combine(this.RepoPath, ".git", "objects", "info")); + File.WriteAllText( + Path.Combine(this.RepoPath, ".git", "objects", "info", "alternates"), + $"{Path.GetRelativePath(objectDatabasePath, Path.Combine(alternate1Path, ".git", "objects"))}:{Path.GetRelativePath(objectDatabasePath, Path.Combine(alternate2Path, ".git", "objects"))}:"); + + using (GitRepository repository = GitRepository.Create(this.RepoPath)) + { + Assert.Equal(localCommit.Sha, repository.GetCommit(GitObjectId.Parse(localCommit.Sha)).Sha.ToString()); + Assert.Equal(alternate1Commit.Sha, repository.GetCommit(GitObjectId.Parse(alternate1Commit.Sha)).Sha.ToString()); + Assert.Equal(alternate2Commit.Sha, repository.GetCommit(GitObjectId.Parse(alternate2Commit.Sha)).Sha.ToString()); + } + } +#endif + + [Fact] + public void GetObjectByShaAndWrongTypeTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var headObjectId = GitObjectId.Parse(this.LibGit2Repository.Head.Tip.Sha); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + Assert.Throws(() => repository.GetObjectBySha(headObjectId, "tree")); + } + } + + [Fact] + public void GetMissingObjectByShaTest() + { + this.InitializeSourceControl(); + this.AddCommits(2); + + var headObjectId = GitObjectId.Parse(this.LibGit2Repository.Head.Tip.Sha); + + using (var repository = GitRepository.Create(this.RepoPath)) + { + Assert.Throws(() => repository.GetObjectBySha(GitObjectId.Parse("7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9"), "commit")); + Assert.Null(repository.GetObjectBySha(GitObjectId.Empty, "commit")); + } + } + + private static void AssertPath(string expected, string actual) + { + Assert.Equal( + Path.GetFullPath(expected), + Path.GetFullPath(actual)); + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/GitTreeStreamingReaderTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitTreeStreamingReaderTests.cs new file mode 100644 index 00000000..b476e3e1 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/GitTreeStreamingReaderTests.cs @@ -0,0 +1,30 @@ +using System.IO; +using System.Text; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class GitTreeStreamingReaderTests + { + [Fact] + public void FindBlobTest() + { + using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin")) + { + var blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("version.json")); + Assert.Equal("59552a5eed6779aa4e5bb4dc96e80f36bb6e7380", blobObjectId.ToString()); + } + } + + [Fact] + public void FindTreeTest() + { + using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin")) + { + var blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("tools")); + Assert.Equal("ec8e91fc4ad13d6a214584330f26d7a05495c8cc", blobObjectId.ToString()); + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/StreamExtensionsTests.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/StreamExtensionsTests.cs new file mode 100644 index 00000000..c0b1c6ec --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/StreamExtensionsTests.cs @@ -0,0 +1,20 @@ +using System.IO; +using Xunit; +using Nerdbank.GitVersioning.ManagedGit; + +namespace ManagedGit +{ + public class StreamExtensionsTests + { + [Fact] + public void ReadTest() + { + byte[] data = new byte[] { 0b10010001, 0b00101110 }; + + using (MemoryStream stream = new MemoryStream(data)) + { + Assert.Equal(5905, stream.ReadMbsInt()); + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/ZLibStreamTest.cs b/src/NerdBank.GitVersioning.Tests/ManagedGit/ZLibStreamTest.cs new file mode 100644 index 00000000..f71827d9 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/ZLibStreamTest.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Nerdbank.GitVersioning.ManagedGit; +using Xunit; + +namespace ManagedGit +{ + public class ZLibStreamTest + { + [Fact] + public void ReadTest() + { + using (Stream rawStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\3596ffe59898103a2675547d4597e742e1f2389c.gz")) + using (ZLibStream stream = new ZLibStream(rawStream, -1)) + using (var sha = SHA1.Create()) + { + var deflateStream = Assert.IsType(stream.BaseStream); + Assert.Same(rawStream, deflateStream.BaseStream); + Assert.Equal(0, stream.Position); + + var hash = sha.ComputeHash(stream); + Assert.Equal("NZb/5ZiYEDomdVR9RZfnQuHyOJw=", Convert.ToBase64String(hash)); + + Assert.Equal(148, stream.Position); + } + } + + [Fact] + public void SeekTest() + { + using (Stream rawStream = TestUtilities.GetEmbeddedResource(@"ManagedGit\3596ffe59898103a2675547d4597e742e1f2389c.gz")) + using (ZLibStream stream = new ZLibStream(rawStream, -1)) + { + // Seek past the commit 137 header, and make sure we can read the 'tree' word + Assert.Equal(11, stream.Seek(11, SeekOrigin.Begin)); + var tree = new byte[4]; + stream.Read(tree); + Assert.Equal("tree", Encoding.UTF8.GetString(tree)); + + // Valid no-ops + Assert.Equal(15, stream.Seek(0, SeekOrigin.Current)); + Assert.Equal(15, stream.Seek(15, SeekOrigin.Begin)); + + // Invalid seeks + Assert.Throws(() => stream.Seek(-1, SeekOrigin.Current)); + Assert.Throws(() => stream.Seek(1, SeekOrigin.Current)); + Assert.Throws(() => stream.Seek(-1, SeekOrigin.End)); + } + } + } +} diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/commit-4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6 b/src/NerdBank.GitVersioning.Tests/ManagedGit/commit-4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6 new file mode 100644 index 00000000..903016e3 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/commit-4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6 @@ -0,0 +1,6 @@ +tree f914b48023c7c804a4f3be780d451f31aef74ac1 +parent 06cc627f28736c0d13506b0414126580fe37c6f3 +author Andrew Arnott 1602013209 -0600 +committer Andrew Arnott 1602013209 -0600 + +Set version to '3.4-alpha' diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/commit-d56dc3ed179053abef2097d1120b4507769bcf1a b/src/NerdBank.GitVersioning.Tests/ManagedGit/commit-d56dc3ed179053abef2097d1120b4507769bcf1a new file mode 100644 index 00000000..7163d498 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/commit-d56dc3ed179053abef2097d1120b4507769bcf1a @@ -0,0 +1,7 @@ +tree f914b48023c7c804a4f3be780d451f31aef74ac1 +parent 4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6 +parent 0989e8fe0cd0e0900173b26decdfb24bc0cc8232 +author Andrew Arnott 1602013209 -0600 +committer Andrew Arnott 1602013209 -0600 + +Merge branch 'v3.3' diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/commit.delta b/src/NerdBank.GitVersioning.Tests/ManagedGit/commit.delta new file mode 100644 index 00000000..65934674 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/commit.delta @@ -0,0 +1,2 @@ +5X4497b0eaaa89abf0e6d70961ad5f04fd3a49cbc6 +parent 0989e8fe0cd0e0900173b26decdfb24bc0cc8232]Merge branch 'v3.3' diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9 .txt b/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9 .txt new file mode 100644 index 00000000..9ae030a9 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9 .txt @@ -0,0 +1,25 @@ +This packfile was generated by running git gc --aggressive on the repository +generated by the VersionHeightResetsWithVersionSpecChanges unit test. + +You can view its contents by running: + git verify-pack -v pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx + +f5b401f40ad83f13030e946c9ea22cb54cb853cd commit 222 169 12 +8738061d8d4f882cac917d7a9de5bcf219e3b91a commit 187 136 181 +d6781552a0a94adbf73ed77696712084754dc274 commit 25 37 317 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +323deb231d7c892739511a4f5432e95cc9413cd1 commit 52 64 354 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +9bedd54fabfce593310f4fd35bd52a22fbe3cca5 commit 53 65 418 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +d249b680c2092c475a3a32cc01a58eb0a8978b42 commit 53 65 483 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +bf108f5ec235d9dd1f743b782e712fa79bdd942c commit 53 65 548 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +c4fb7407f0d68e80bbda698989d6346731bfe374 commit 52 64 613 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +3c9254a93b2d4f067cda42b2bb4a33bf9a139301 commit 53 64 677 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +4f46ec9d5cad446b0ad6dd73041fa4a14a6edc5d commit 53 65 741 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +12b37694cb666cc32c09bd15c76912aec9fd3ef9 commit 53 65 806 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +b4d44127e76276f3b389481956ac1a2a6df5fac3 commit 53 64 871 1 8738061d8d4f882cac917d7a9de5bcf219e3b91a +ade83ea20b5882d81ad7addebca6c42886bbcf41 blob 174 140 935 +e2ddd74b76b4b557d36b8b29b9bc969ebb15ba79 tree 40 51 1075 +126a30e8740bae4525e9d90dfcbd235fc04187c6 tree 40 51 1126 +03284b6463f7f46e830a9ae0fb208a10bba48a15 blob 20 32 1177 1 ade83ea20b5882d81ad7addebca6c42886bbcf41 +non delta: 5 objects +chain length = 1: 11 objects +.git\objects\pack\pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.pack: ok \ No newline at end of file diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx b/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx new file mode 100644 index 00000000..005813d8 Binary files /dev/null and b/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.idx differ diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.pack b/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.pack new file mode 100644 index 00000000..59f3e201 Binary files /dev/null and b/src/NerdBank.GitVersioning.Tests/ManagedGit/pack-7d6b2c56ffb97eedb92f4e28583c093f7ee4b3d9.pack differ diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/tree-bb36cf0ca445ccc8e5ce9cc88f7cf74128e96dc9 b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree-bb36cf0ca445ccc8e5ce9cc88f7cf74128e96dc9 new file mode 100644 index 00000000..826ac799 --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree-bb36cf0ca445ccc8e5ce9cc88f7cf74128e96dc9 @@ -0,0 +1,24 @@ +100644 blob 98824c08ae19a0fcd17487f6dd163c9375174e90 .editorconfig +100644 blob e9ae1b43947e3832b0dbc064179f46eec35aa8b1 .gitattributes +040000 tree d7ef61eb64243d30552bda9de6de94d368855fdf .github +100644 blob f62635e01f3d9f6dffdb88a3fc255fb5636c6c38 .gitignore +100644 blob ccb9654d76d331adfc86c03c7ee211afd57c829d .gitmodules +100644 blob 775f221c98e117849f2dff6b3f6c0965935ddd36 CODE-OF-CONDUCT.md +100644 blob 09656a68a1ee6c4ef3e1d1981e7313612f540f92 CONTRIBUTING.md +100644 blob 3fae055e8f95a702c8ad87118954a5c37dc1c9ad CONTRIBUTORS +100644 blob 9a833a03454615f40b83db5870d0296c1789792e Directory.Build.rsp +100644 blob 56e51b1c06b5c4dead2b36c916acd983c347966e LICENSE +100644 blob 786a72bf6c10e28f5fcd801b7badf457a585a833 NOTICE.md +100644 blob e683c5d2e44077b81ad545f6648d938d79076eef README.md +100644 blob 228bd3155183cadcd9bc18685a95ade076620a9a azure-pipelines.yml +040000 tree c6b4b836da8f42abc6ca2d0122bb7bd8cb8dcaf9 azure-pipelines +100644 blob 7cb727e0ddf77480194ae7c85ed1fbafdfb32f9e build.cmd +100644 blob f49055e51191ca37b5bc84faf20dde7ca0d63e1c build.ps1 +040000 tree aa92c5a940b472c44e722cbb25209b0e9e5c1d70 doc +100644 blob e9aac8c222ec33ebb429e5cf8f9eeeff66588717 global.json +100644 blob 970285c2f4bc72e54b10b058c9629867880eec08 init.cmd +100755 blob 8b11d60b6d4112782ddbcec198f65433e9e375db init.ps1 +040000 tree f3264024e88924d229cdcff93ee654f6c6019bcd src +040000 tree ec8e91fc4ad13d6a214584330f26d7a05495c8cc tools +100644 blob 8f0e0c8831c0ff516d3899b3b1fd02d8f7c0e12a version.json +160000 commit 45e49432d270bef14295024b6ac02ae804747908 wiki diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/tree-f914b48023c7c804a4f3be780d451f31aef74ac1 b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree-f914b48023c7c804a4f3be780d451f31aef74ac1 new file mode 100644 index 00000000..092b1bbf --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree-f914b48023c7c804a4f3be780d451f31aef74ac1 @@ -0,0 +1,24 @@ +100644 blob 98824c08ae19a0fcd17487f6dd163c9375174e90 .editorconfig +100644 blob e9ae1b43947e3832b0dbc064179f46eec35aa8b1 .gitattributes +040000 tree d7ef61eb64243d30552bda9de6de94d368855fdf .github +100644 blob f62635e01f3d9f6dffdb88a3fc255fb5636c6c38 .gitignore +100644 blob ccb9654d76d331adfc86c03c7ee211afd57c829d .gitmodules +100644 blob 775f221c98e117849f2dff6b3f6c0965935ddd36 CODE-OF-CONDUCT.md +100644 blob 09656a68a1ee6c4ef3e1d1981e7313612f540f92 CONTRIBUTING.md +100644 blob 3fae055e8f95a702c8ad87118954a5c37dc1c9ad CONTRIBUTORS +100644 blob 9a833a03454615f40b83db5870d0296c1789792e Directory.Build.rsp +100644 blob 56e51b1c06b5c4dead2b36c916acd983c347966e LICENSE +100644 blob 786a72bf6c10e28f5fcd801b7badf457a585a833 NOTICE.md +100644 blob e683c5d2e44077b81ad545f6648d938d79076eef README.md +100644 blob 228bd3155183cadcd9bc18685a95ade076620a9a azure-pipelines.yml +040000 tree c6b4b836da8f42abc6ca2d0122bb7bd8cb8dcaf9 azure-pipelines +100644 blob 7cb727e0ddf77480194ae7c85ed1fbafdfb32f9e build.cmd +100644 blob f49055e51191ca37b5bc84faf20dde7ca0d63e1c build.ps1 +040000 tree aa92c5a940b472c44e722cbb25209b0e9e5c1d70 doc +100644 blob e9aac8c222ec33ebb429e5cf8f9eeeff66588717 global.json +100644 blob 970285c2f4bc72e54b10b058c9629867880eec08 init.cmd +100755 blob 8b11d60b6d4112782ddbcec198f65433e9e375db init.ps1 +040000 tree f3264024e88924d229cdcff93ee654f6c6019bcd src +040000 tree ec8e91fc4ad13d6a214584330f26d7a05495c8cc tools +100644 blob 59552a5eed6779aa4e5bb4dc96e80f36bb6e7380 version.json +160000 commit 45e49432d270bef14295024b6ac02ae804747908 wiki diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/tree.bin b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree.bin new file mode 100644 index 00000000..69bcd7e0 Binary files /dev/null and b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree.bin differ diff --git a/src/NerdBank.GitVersioning.Tests/ManagedGit/tree.delta b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree.delta new file mode 100644 index 00000000..36b9791a --- /dev/null +++ b/src/NerdBank.GitVersioning.Tests/ManagedGit/tree.delta @@ -0,0 +1 @@ + (59552a5eed6779aa4e5bb4dc96e80f36bb6e7380J \ No newline at end of file diff --git a/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj b/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj index 698d82dc..2e6f8278 100644 --- a/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj +++ b/src/NerdBank.GitVersioning.Tests/NerdBank.GitVersioning.Tests.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1;net461 + net5.0;net461 true true full @@ -19,6 +19,10 @@ + + + + @@ -33,19 +37,21 @@ - - - + - + all runtime; build; native; contentfiles; analyzers + + + + diff --git a/src/NerdBank.GitVersioning.Tests/ReleaseManagerTests.cs b/src/NerdBank.GitVersioning.Tests/ReleaseManagerTests.cs index 1bba642f..c69f5120 100644 --- a/src/NerdBank.GitVersioning.Tests/ReleaseManagerTests.cs +++ b/src/NerdBank.GitVersioning.Tests/ReleaseManagerTests.cs @@ -14,9 +14,34 @@ using ReleaseVersionIncrement = Nerdbank.GitVersioning.VersionOptions.ReleaseVersionIncrement; using Version = System.Version; -public class ReleaseManagerTests : RepoTestBase +[Trait("Engine", "Managed")] +public class ReleaseManagerManagedTests : ReleaseManagerTests { - public ReleaseManagerTests(ITestOutputHelper logger) : base(logger) + public ReleaseManagerManagedTests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: false); +} + +[Trait("Engine", "LibGit2")] +public class ReleaseManagerLibGit2Tests : ReleaseManagerTests +{ + public ReleaseManagerLibGit2Tests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: true); +} + +public abstract class ReleaseManagerTests : RepoTestBase +{ + public ReleaseManagerTests(ITestOutputHelper logger) + : base(logger) { } @@ -49,7 +74,7 @@ public void PrepareRelease_DirtyIndex() // create a file and stage it var filePath = Path.Combine(this.RepoPath, "file1.txt"); File.WriteAllText(filePath, ""); - Commands.Stage(this.Repo, filePath); + Commands.Stage(this.LibGit2Repository, filePath); // running PrepareRelease should result in an error // because there are uncommitted changes @@ -104,7 +129,7 @@ public void PrepareRelease_ReleaseBranchAlreadyExists() this.WriteVersionFile(versionOptions); - this.Repo.CreateBranch("release/v1.2"); + this.LibGit2Repository.CreateBranch("release/v1.2"); // running PrepareRelease should result in an error // because the release branch already exists @@ -152,9 +177,9 @@ public void PrepareRelease_ReleaseBranch(string initialVersion, string releaseOp // switch to release branch var branchName = releaseBranchName; - Commands.Checkout(this.Repo, this.Repo.CreateBranch(branchName)); + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.CreateBranch(branchName)); - var tipBeforePrepareRelease = this.Repo.Head.Tip; + var tipBeforePrepareRelease = this.LibGit2Repository.Head.Tip; // run PrepareRelease var releaseManager = new ReleaseManager(); @@ -162,7 +187,7 @@ public void PrepareRelease_ReleaseBranch(string initialVersion, string releaseOp // Check if a commit was created { - var updateVersionCommit = this.Repo.Head.Tip; + var updateVersionCommit = this.LibGit2Repository.Head.Tip; Assert.NotEqual(tipBeforePrepareRelease.Id, updateVersionCommit.Id); Assert.Single(updateVersionCommit.Parents); Assert.Equal(updateVersionCommit.Parents.Single().Id, tipBeforePrepareRelease.Id); @@ -170,7 +195,7 @@ public void PrepareRelease_ReleaseBranch(string initialVersion, string releaseOp // check version on release branch { - var actualVersionOptions = VersionFile.GetVersion(this.Repo.Branches[branchName].Tip); + var actualVersionOptions = this.GetVersionOptions(committish: this.LibGit2Repository.Branches[branchName].Tip.Sha); Assert.Equal(expectedVersionOptions, actualVersionOptions); } } @@ -188,7 +213,7 @@ public void PrepeareRelease_ReleaseBranchWithVersionDecrement(string initialVers this.WriteVersionFile(versionOptions); // switch to release branch - Commands.Checkout(this.Repo, this.Repo.CreateBranch(branchName)); + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.CreateBranch(branchName)); // running PrepareRelease should result in an error // because we're trying to add a prerelease tag to a version without prerelease tag @@ -290,22 +315,22 @@ public void PrepareRelease_Master( } }; - var initialBranchName = this.Repo.Head.FriendlyName; - var tipBeforePrepareRelease = this.Repo.Head.Tip; + var initialBranchName = this.LibGit2Repository.Head.FriendlyName; + var tipBeforePrepareRelease = this.LibGit2Repository.Head.Tip; // prepare release var releaseManager = new ReleaseManager(); releaseManager.PrepareRelease(this.RepoPath, releaseUnstableTag, (nextVersion == null ? null : Version.Parse(nextVersion)), parameterVersionIncrement); // check if a branch was created - Assert.Contains(this.Repo.Branches, branch => branch.FriendlyName == expectedBranchName); + Assert.Contains(this.LibGit2Repository.Branches, branch => branch.FriendlyName == expectedBranchName); // PrepareRelease should switch back to the initial branch - Assert.Equal(initialBranchName, this.Repo.Head.FriendlyName); + Assert.Equal(initialBranchName, this.LibGit2Repository.Head.FriendlyName); // check if release branch contains a new commit // parent of new commit must be the commit before preparing the release - var releaseBranch = this.Repo.Branches[expectedBranchName]; + var releaseBranch = this.LibGit2Repository.Branches[expectedBranchName]; { // If the original branch had no -prerelease tag, the release branch has no commit to author. if (string.IsNullOrEmpty(initialVersionOptions.Version.Prerelease)) @@ -322,7 +347,7 @@ public void PrepareRelease_Master( if (string.IsNullOrEmpty(initialVersionOptions.Version.Prerelease)) { // Verify that one commit was authored. - var incrementCommit = this.Repo.Head.Tip; + var incrementCommit = this.LibGit2Repository.Head.Tip; Assert.Single(incrementCommit.Parents); Assert.Equal(tipBeforePrepareRelease.Id, incrementCommit.Parents.Single().Id); } @@ -331,7 +356,7 @@ public void PrepareRelease_Master( // check if current branch contains new commits // - one commit that updates the version (parent must be the commit before preparing the release) // - one commit merging the release branch back to master and resolving the conflict - var mergeCommit = this.Repo.Head.Tip; + var mergeCommit = this.LibGit2Repository.Head.Tip; Assert.Equal(2, mergeCommit.Parents.Count()); Assert.Equal(releaseBranch.Tip.Id, mergeCommit.Parents.Skip(1).First().Id); @@ -342,13 +367,13 @@ public void PrepareRelease_Master( // check version on release branch { - var releaseBranchVersion = VersionFile.GetVersion(releaseBranch.Tip); + var releaseBranchVersion = this.GetVersionOptions(committish: releaseBranch.Tip.Sha); Assert.Equal(expectedVersionOptionsReleaseBranch, releaseBranchVersion); } // check version on master branch { - var currentBranchVersion = VersionFile.GetVersion(this.Repo.Head.Tip); + var currentBranchVersion = this.GetVersionOptions(committish: this.LibGit2Repository.Head.Tip.Sha); Assert.Equal(expectedVersionOptionsCurrentBrach, currentBranchVersion); } } @@ -378,7 +403,7 @@ public void PrepareRelease_DetachedHead() { this.InitializeSourceControl(); this.WriteVersionFile("1.0", "-alpha"); - Commands.Checkout(this.Repo, this.Repo.Head.Commits.First()); + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.Head.Commits.First()); var ex = Assert.Throws(() => new ReleaseManager().PrepareRelease(this.RepoPath)); Assert.Equal(ReleasePreparationError.DetachedHead, ex.Error); } @@ -438,7 +463,7 @@ public void PrepareRelease_JsonOutput() }; this.WriteVersionFile(versionOptions); - var currentBranchName = this.Repo.Head.FriendlyName; + var currentBranchName = this.LibGit2Repository.Head.FriendlyName; var releaseBranchName = "v1.0"; // run release preparation @@ -465,8 +490,8 @@ public void PrepareRelease_JsonOutput() // check "CurrentBranch" output { - var expectedCommitId = this.Repo.Branches[currentBranchName].Tip.Sha; - var expectedVersion = VersionFile.GetVersion(this.Repo.Branches[currentBranchName].Tip).Version.ToString(); + var expectedCommitId = this.LibGit2Repository.Branches[currentBranchName].Tip.Sha; + var expectedVersion = this.GetVersionOptions(committish: this.LibGit2Repository.Branches[currentBranchName].Tip.Sha).Version.ToString(); var currentBranchOutput = jsonOutput.Property("CurrentBranch")?.Value as JObject; Assert.NotNull(currentBranchOutput); @@ -479,8 +504,8 @@ public void PrepareRelease_JsonOutput() // Check "NewBranch" output { - var expectedCommitId = this.Repo.Branches[releaseBranchName].Tip.Sha; - var expectedVersion = VersionFile.GetVersion(this.Repo.Branches[releaseBranchName].Tip).Version.ToString(); + var expectedCommitId = this.LibGit2Repository.Branches[releaseBranchName].Tip.Sha; + var expectedVersion = this.GetVersionOptions(committish: this.LibGit2Repository.Branches[releaseBranchName].Tip.Sha).Version.ToString(); var newBranchOutput = jsonOutput.Property("NewBranch")?.Value as JObject; Assert.NotNull(newBranchOutput); @@ -511,7 +536,7 @@ public void PrepareRelease_JsonOutputWhenUpdatingReleaseBranch() var branchName = "v1.0"; // switch to release branch - Commands.Checkout(this.Repo, this.Repo.CreateBranch(branchName)); + Commands.Checkout(this.LibGit2Repository, this.LibGit2Repository.CreateBranch(branchName)); // run release preparation var stdout = new StringWriter(); @@ -533,8 +558,8 @@ public void PrepareRelease_JsonOutputWhenUpdatingReleaseBranch() // check "CurrentBranch" output { - var expectedCommitId = this.Repo.Branches[branchName].Tip.Sha; - var expectedVersion = VersionFile.GetVersion(this.Repo.Branches[branchName].Tip).Version.ToString(); + var expectedCommitId = this.LibGit2Repository.Branches[branchName].Tip.Sha; + var expectedVersion = this.GetVersionOptions(committish: this.LibGit2Repository.Branches[branchName].Tip.Sha).Version.ToString(); var currentBranchOutput = jsonOutput.Property("CurrentBranch")?.Value as JObject; Assert.NotNull(currentBranchOutput); @@ -578,21 +603,22 @@ public void PrepareRelease_ResetsVersionHeightOffset() // create version.json this.WriteVersionFile(initialVersionOptions); - var tipBeforePrepareRelease = this.Repo.Head.Tip; + var tipBeforePrepareRelease = this.LibGit2Repository.Head.Tip; var releaseManager = new ReleaseManager(); releaseManager.PrepareRelease(this.RepoPath); - var newVersion = VersionFile.GetVersion(this.RepoPath); + this.SetContextToHead(); + var newVersion = this.Context.VersionFile.GetVersion(); Assert.Equal(expectedMainVersionOptions, newVersion); - var releaseVersion = VersionFile.GetVersion(this.Repo.Branches["v1.0"].Tip); + VersionOptions releaseVersion = this.GetVersionOptions(committish: this.LibGit2Repository.Branches["v1.0"].Tip.Sha); Assert.Equal(expectedReleaseVersionOptions, releaseVersion); } - protected override void InitializeSourceControl() + protected override void InitializeSourceControl(bool withInitialCommit = true) { - base.InitializeSourceControl(); + base.InitializeSourceControl(withInitialCommit); this.Ignore_git2_UntrackedFile(); } diff --git a/src/NerdBank.GitVersioning.Tests/RepoTestBase.Helpers.cs b/src/NerdBank.GitVersioning.Tests/RepoTestBase.Helpers.cs index 627d6912..09ba29d4 100644 --- a/src/NerdBank.GitVersioning.Tests/RepoTestBase.Helpers.cs +++ b/src/NerdBank.GitVersioning.Tests/RepoTestBase.Helpers.cs @@ -1,37 +1,22 @@ -using System; +#nullable enable + +using System; +using System.Collections.Generic; using System.IO; using LibGit2Sharp; using Nerdbank.GitVersioning; public partial class RepoTestBase { - /// - /// Gets the number of commits in the longest single path between - /// the specified commit and the most distant ancestor (inclusive) - /// that set the version to the value at the tip of the . - /// - /// The branch to measure the height of. - /// The repo-relative project directory for which to calculate the version. - /// The height of the branch till the version is changed. - protected int GetVersionHeight(Branch branch, string repoRelativeProjectDirectory = null) - { - var commit = branch.Tip ?? throw new InvalidOperationException("No commit exists."); - return this.GetVersionHeight(commit, repoRelativeProjectDirectory); - } /// /// Gets the number of commits in the longest single path between /// the specified commit and the most distant ancestor (inclusive) /// that set the version to the value at . /// - /// The commit to measure the height of. + /// The commit, branch or tag to measure the height of. Leave as null to check HEAD. /// The repo-relative project directory for which to calculate the version. - /// Optional base version to calculate the height. If not specified, the base version will be calculated by scanning the repository. /// The height of the commit. Always a positive integer. - protected int GetVersionHeight(Commit commit, string repoRelativeProjectDirectory = null) - { - VersionOracle oracle = new VersionOracle(repoRelativeProjectDirectory == null ? this.RepoPath : Path.Combine(this.RepoPath, repoRelativeProjectDirectory), this.Repo, commit, null); - return oracle.VersionHeight; - } + protected int GetVersionHeight(string? committish, string? repoRelativeProjectDirectory = null) => this.GetVersionOracle(repoRelativeProjectDirectory, committish).VersionHeight; /// /// Gets the number of commits in the longest single path between @@ -41,54 +26,33 @@ protected int GetVersionHeight(Commit commit, string repoRelativeProjectDirector /// /// The repo-relative project directory for which to calculate the version. /// The height of the repo at HEAD. Always a positive integer. - protected int GetVersionHeight(string repoRelativeProjectDirectory = null) - { - return GetVersionHeight(this.Repo, repoRelativeProjectDirectory); - } + protected int GetVersionHeight(string? repoRelativeProjectDirectory = null) => this.GetVersionHeight(committish: null, repoRelativeProjectDirectory); - protected static int GetVersionHeight(Repository repository, string repoRelativeProjectDirectory = null) + private class FakeCloudBuild : ICloudBuild { - VersionOracle oracle = new VersionOracle(repoRelativeProjectDirectory == null ? repository.Info.WorkingDirectory : Path.Combine(repository.Info.WorkingDirectory, repoRelativeProjectDirectory), repository, null); - return oracle.VersionHeight; - } + public FakeCloudBuild(string gitCommitId) + { + this.GitCommitId = gitCommitId; + } - /// - /// Encodes HEAD (or a modified working copy) from history in a - /// so that the original commit can be found later. - /// - /// The repo-relative project directory for which to calculate the version. - /// - /// A version whose and - /// components are calculated based on the commit. - /// - /// - /// In the returned version, the component is - /// the height of the git commit while the - /// component is the first four bytes of the git commit id (forced to be a positive integer). - /// - protected System.Version GetIdAsVersion(string repoRelativeProjectDirectory = null) - { - VersionOracle oracle = new VersionOracle( - repoRelativeProjectDirectory == null ? this.Repo.Info.WorkingDirectory : Path.Combine(this.Repo.Info.WorkingDirectory, repoRelativeProjectDirectory), - this.Repo, - null); + public bool IsApplicable => true; - return oracle.Version; - } + public bool IsPullRequest => false; - protected System.Version GetIdAsVersion(Commit commit, string repoRelativeProjectDirectory = null) - { - return GetIdAsVersion(this.Repo, commit, repoRelativeProjectDirectory); - } + public string? BuildingBranch => null; - protected static System.Version GetIdAsVersion(Repository repository, Commit commit, string repoRelativeProjectDirectory = null) - { - VersionOracle oracle = new VersionOracle( - repoRelativeProjectDirectory == null ? repository.Info.WorkingDirectory : Path.Combine(repository.Info.WorkingDirectory, repoRelativeProjectDirectory), - repository, - commit, - null); + public string? BuildingTag => null; + + public string? GitCommitId { get; private set; } + + public IReadOnlyDictionary SetCloudBuildNumber(string buildNumber, TextWriter? stdout, TextWriter? stderr) + { + return new Dictionary(); + } - return oracle.Version; + public IReadOnlyDictionary SetCloudBuildVariable(string name, string value, TextWriter? stdout, TextWriter? stderr) + { + return new Dictionary(); + } } } diff --git a/src/NerdBank.GitVersioning.Tests/RepoTestBase.cs b/src/NerdBank.GitVersioning.Tests/RepoTestBase.cs index 9711bb33..319349e6 100644 --- a/src/NerdBank.GitVersioning.Tests/RepoTestBase.cs +++ b/src/NerdBank.GitVersioning.Tests/RepoTestBase.cs @@ -1,8 +1,12 @@ -using System; +#nullable enable + +using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using LibGit2Sharp; using Nerdbank.GitVersioning; +using Nerdbank.GitVersioning.LibGit2; using Validation; using Xunit.Abstractions; @@ -20,28 +24,51 @@ public RepoTestBase(ITestOutputHelper logger) this.Logger = logger; this.RepoPath = this.CreateDirectoryForNewRepo(); + this.Context = this.CreateGitContext(this.RepoPath); } protected ITestOutputHelper Logger { get; } - protected Repository Repo { get; set; } + protected GitContext? Context { get; set; } protected string RepoPath { get; set; } protected Signature Signer => new Signature("a", "a@a.com", new DateTimeOffset(2015, 8, 2, 0, 0, 0, TimeSpan.Zero)); + protected Repository? LibGit2Repository { get; private set; } + public void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); } + protected abstract GitContext CreateGitContext(string path, string? committish = null); + + protected void SetContextToHead() => Assumes.True(this.Context?.TrySelectCommit("HEAD") ?? false); + + protected VersionOptions? GetVersionOptions(string? path = null, string? committish = null) + { + Debug.Assert(path?.Length != 40, "commit passed as path"); + + using var context = this.CreateGitContext(path is null ? this.RepoPath : Path.Combine(this.RepoPath, path), committish); + return context.VersionFile.GetVersion(); + } + + protected VersionOracle GetVersionOracle(string? path = null, string? committish = null) + { + using var context = this.CreateGitContext(path is null ? this.RepoPath : Path.Combine(this.RepoPath, path), committish); + return new VersionOracle(context); + } + + protected System.Version GetVersion(string? path = null, string? committish = null) => this.GetVersionOracle(path, committish).Version; + protected string CreateDirectoryForNewRepo() { string repoPath; do { - repoPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + repoPath = Path.Combine(Path.GetTempPath(), this.GetType().Name + "_" + Path.GetRandomFileName()); } while (Directory.Exists(repoPath)); Directory.CreateDirectory(repoPath); @@ -53,7 +80,8 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - this.Repo?.Dispose(); + this.LibGit2Repository?.Dispose(); + this.Context?.Dispose(); foreach (string dir in this.repoDirectories) { try @@ -68,44 +96,56 @@ protected virtual void Dispose(bool disposing) } } - protected virtual void InitializeSourceControl() + protected virtual void InitializeSourceControl(bool withInitialCommit = true) { - Repository.Init(this.RepoPath); - this.Repo = new Repository(this.RepoPath); - this.Repo.Config.Set("user.name", this.Signer.Name, ConfigurationLevel.Local); - this.Repo.Config.Set("user.email", this.Signer.Email, ConfigurationLevel.Local); - foreach (var file in this.Repo.RetrieveStatus().Untracked) + this.Context?.Dispose(); + this.Context = this.InitializeSourceControl(this.RepoPath, withInitialCommit); + this.LibGit2Repository = new Repository(this.RepoPath); + } + + protected virtual GitContext InitializeSourceControl(string repoPath, bool withInitialCommit = true) + { + Repository.Init(repoPath); + var repo = new Repository(repoPath); + repo.Config.Set("user.name", this.Signer.Name, ConfigurationLevel.Local); + repo.Config.Set("user.email", this.Signer.Email, ConfigurationLevel.Local); + foreach (var file in repo.RetrieveStatus().Untracked) { if (!Path.GetFileName(file.FilePath).StartsWith("_git2_", StringComparison.Ordinal)) { - Commands.Stage(this.Repo, file.FilePath); + Commands.Stage(repo, file.FilePath); } } - if (this.Repo.Index.Count > 0) + if (repo.Index.Count > 0 && withInitialCommit) { - this.Repo.Commit("initial commit", this.Signer, this.Signer); + repo.Commit("initial commit", this.Signer, this.Signer); } + + repo.Dispose(); + return this.CreateGitContext(repoPath); } protected void Ignore_git2_UntrackedFile() { string gitIgnoreFilePath = Path.Combine(this.RepoPath, ".gitignore"); File.WriteAllLines(gitIgnoreFilePath, new[] { "_git2_*" }); - Commands.Stage(this.Repo, gitIgnoreFilePath); - this.Repo.Commit("Ignore _git2_ files.", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, gitIgnoreFilePath); + this.LibGit2Repository.Commit("Ignore _git2_ files.", this.Signer, this.Signer); } protected void AddCommits(int count = 1) { - Verify.Operation(this.Repo != null, "Repo has not been created yet."); + Verify.Operation(this.LibGit2Repository is object, "Repo has not been created yet."); for (int i = 1; i <= count; i++) { - this.Repo.Commit($"filler commit {i}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + this.LibGit2Repository.Commit($"filler commit {i}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); } + + this.SetContextToHead(); } - protected Commit WriteVersionTxtFile(string version = "1.2", string prerelease = "", string relativeDirectory = null) + protected Commit? WriteVersionTxtFile(string version = "1.2", string prerelease = "", string? relativeDirectory = null) { if (relativeDirectory == null) { @@ -117,13 +157,13 @@ protected Commit WriteVersionTxtFile(string version = "1.2", string prerelease = return this.CommitVersionFile(versionFilePath, $"{version}{prerelease}"); } - protected Commit WriteVersionFile(string version = "1.2", string prerelease = "", string relativeDirectory = null) + protected Commit? WriteVersionFile(string version = "1.2", string prerelease = "", string? relativeDirectory = null) { var versionData = VersionOptions.FromVersion(new System.Version(version), prerelease); return this.WriteVersionFile(versionData, relativeDirectory); } - protected Commit WriteVersionFile(VersionOptions versionData, string relativeDirectory = null) + protected Commit? WriteVersionFile(VersionOptions versionData, string? relativeDirectory = null) { Requires.NotNull(versionData, nameof(versionData)); @@ -132,30 +172,44 @@ protected Commit WriteVersionFile(VersionOptions versionData, string relativeDir relativeDirectory = string.Empty; } - string versionFilePath = VersionFile.SetVersion(Path.Combine(this.RepoPath, relativeDirectory), versionData); - return this.CommitVersionFile(versionFilePath, versionData.Version?.ToString()); + bool localContextCreated = this.Context is null; + var context = this.Context ?? GitContext.Create(this.RepoPath, writable: true); + try + { + string versionFilePath = context.VersionFile.SetVersion(Path.Combine(this.RepoPath, relativeDirectory), versionData); + return this.CommitVersionFile(versionFilePath, versionData.Version?.ToString()); + } + finally + { + if (localContextCreated) + { + context.Dispose(); + } + } } - protected Commit CommitVersionFile(string versionFilePath, string version) + protected Commit? CommitVersionFile(string versionFilePath, string? version) { Requires.NotNullOrEmpty(versionFilePath, nameof(versionFilePath)); Requires.NotNullOrEmpty(versionFilePath, nameof(versionFilePath)); - if (this.Repo != null) + if (this.LibGit2Repository is object) { Assumes.True(versionFilePath.StartsWith(this.RepoPath, StringComparison.OrdinalIgnoreCase)); var relativeFilePath = versionFilePath.Substring(this.RepoPath.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - Commands.Stage(this.Repo, relativeFilePath); + Commands.Stage(this.LibGit2Repository, relativeFilePath); if (Path.GetExtension(relativeFilePath) == ".json") { string txtFilePath = relativeFilePath.Substring(0, relativeFilePath.Length - 4) + "txt"; - if (!File.Exists(Path.Combine(this.RepoPath, txtFilePath)) && this.Repo.Index[txtFilePath] != null) + if (!File.Exists(Path.Combine(this.RepoPath, txtFilePath)) && this.LibGit2Repository.Index[txtFilePath] != null) { - this.Repo.Index.Remove(txtFilePath); + this.LibGit2Repository.Index.Remove(txtFilePath); } } - return this.Repo.Commit($"Add/write {relativeFilePath} set to {version ?? "Inherited"}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + Commit? result = this.LibGit2Repository.Commit($"Add/write {relativeFilePath} set to {version ?? "Inherited"}", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + this.SetContextToHead(); + return result; } return null; diff --git a/src/NerdBank.GitVersioning.Tests/TestUtilities.cs b/src/NerdBank.GitVersioning.Tests/TestUtilities.cs index 36b74d62..dac1daec 100644 --- a/src/NerdBank.GitVersioning.Tests/TestUtilities.cs +++ b/src/NerdBank.GitVersioning.Tests/TestUtilities.cs @@ -43,12 +43,19 @@ internal static void DeleteDirectory(string path) } } + internal static Stream GetEmbeddedResource(string resourcePath) + { + Requires.NotNullOrEmpty(resourcePath, nameof(resourcePath)); + + return Assembly.GetExecutingAssembly().GetManifestResourceStream($"{ThisAssembly.RootNamespace}.{resourcePath.Replace('\\', '.')}"); + } + internal static void ExtractEmbeddedResource(string resourcePath, string extractedFilePath) { Requires.NotNullOrEmpty(resourcePath, nameof(resourcePath)); Requires.NotNullOrEmpty(extractedFilePath, nameof(extractedFilePath)); - using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream($"{ThisAssembly.RootNamespace}.{resourcePath.Replace('\\', '.')}")) + using (var stream = GetEmbeddedResource(resourcePath)) { Requires.Argument(stream != null, nameof(resourcePath), "Resource not found."); using (var extractedFile = File.OpenWrite(extractedFilePath)) diff --git a/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs b/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs index 2a65aeaa..77c2fbe1 100644 --- a/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs +++ b/src/NerdBank.GitVersioning.Tests/VersionFileTests.cs @@ -11,29 +11,40 @@ using Xunit.Abstractions; using Version = System.Version; -public class VersionFileTests : RepoTestBase +[Trait("Engine", "Managed")] +public class VersionFileManagedTests : VersionFileTests { - private string versionTxtPath; - private string versionJsonPath; - - public VersionFileTests(ITestOutputHelper logger) + public VersionFileManagedTests(ITestOutputHelper logger) : base(logger) { - this.versionTxtPath = Path.Combine(this.RepoPath, VersionFile.TxtFileName); - this.versionJsonPath = Path.Combine(this.RepoPath, VersionFile.JsonFileName); } - [Fact] - public void IsVersionDefined_Commit_Null() + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: false); +} + +[Trait("Engine", "LibGit2")] +public class VersionFileLibGit2Tests : VersionFileTests +{ + public VersionFileLibGit2Tests(ITestOutputHelper logger) + : base(logger) { - Assert.False(VersionFile.IsVersionDefined((Commit)null)); } - [Fact] - public void IsVersionDefined_String_NullOrEmpty() + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: true); +} + +public abstract class VersionFileTests : RepoTestBase +{ + private string versionTxtPath; + private string versionJsonPath; + + public VersionFileTests(ITestOutputHelper logger) + : base(logger) { - Assert.Throws(() => VersionFile.IsVersionDefined((string)null)); - Assert.Throws(() => VersionFile.IsVersionDefined(string.Empty)); + this.versionTxtPath = Path.Combine(this.RepoPath, VersionFile.TxtFileName); + this.versionJsonPath = Path.Combine(this.RepoPath, VersionFile.JsonFileName); } [Fact] @@ -41,14 +52,16 @@ public void IsVersionDefined_Commit() { this.InitializeSourceControl(); this.AddCommits(); - Assert.False(VersionFile.IsVersionDefined(this.Repo.Head.Commits.First())); + Assert.False(this.Context.VersionFile.IsVersionDefined()); this.WriteVersionFile(); // Verify that we can find the version.txt file in the most recent commit, // But not in the initial commit. - Assert.True(VersionFile.IsVersionDefined(this.Repo.Head.Commits.First())); - Assert.False(VersionFile.IsVersionDefined(this.Repo.Head.Commits.Last())); + using var tipContext = this.CreateGitContext(this.RepoPath, this.LibGit2Repository.Head.Commits.First().Sha); + using var initialContext = this.CreateGitContext(this.RepoPath, this.LibGit2Repository.Head.Commits.Last().Sha); + Assert.True(tipContext.VersionFile.IsVersionDefined()); + Assert.False(initialContext.VersionFile.IsVersionDefined()); } [Fact] @@ -60,17 +73,20 @@ public void IsVersionDefined_String_ConsiderAncestorFolders() b <- 1.1 c (inherits 1.1) */ - VersionFile.SetVersion(this.RepoPath, new Version(1, 0)); + this.Context.VersionFile.SetVersion(this.RepoPath, new Version(1, 0)); string subDirA = Path.Combine(this.RepoPath, "a"); string subDirAB = Path.Combine(subDirA, "b"); string subDirABC = Path.Combine(subDirAB, "c"); Directory.CreateDirectory(subDirABC); - VersionFile.SetVersion(subDirAB, new Version(1, 1)); - - Assert.True(VersionFile.IsVersionDefined(subDirABC)); - Assert.True(VersionFile.IsVersionDefined(subDirAB)); - Assert.True(VersionFile.IsVersionDefined(subDirA)); - Assert.True(VersionFile.IsVersionDefined(this.RepoPath)); + this.Context.VersionFile.SetVersion(subDirAB, new Version(1, 1)); + + using var subDirABCContext = this.CreateGitContext(subDirABC); + using var subDirABContext = this.CreateGitContext(subDirAB); + using var subDirAContext = this.CreateGitContext(subDirA); + Assert.True(subDirABCContext.VersionFile.IsVersionDefined()); + Assert.True(subDirABContext.VersionFile.IsVersionDefined()); + Assert.True(subDirAContext.VersionFile.IsVersionDefined()); + Assert.True(this.Context.VersionFile.IsVersionDefined()); } [Theory] @@ -84,7 +100,7 @@ public void GetVersion_JsonCompatibility(string version, string assemblyVersion, { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.JsonFileName), json); - var options = VersionFile.GetVersion(this.RepoPath); + var options = this.Context.VersionFile.GetVersion(); Assert.NotNull(options); Assert.Equal(version, options.Version?.ToString()); Assert.Equal(assemblyVersion, options.AssemblyVersion?.Version?.ToString()); @@ -101,13 +117,13 @@ public void GetVersion_JsonCompatibility(string version, string assemblyVersion, [InlineData("2.3.0", "-rc")] public void SetVersion_GetVersionFromFile(string expectedVersion, string expectedPrerelease) { - string pathWritten = VersionFile.SetVersion(this.RepoPath, new Version(expectedVersion), expectedPrerelease); + string pathWritten = this.Context.VersionFile.SetVersion(this.RepoPath, new Version(expectedVersion), expectedPrerelease); Assert.Equal(Path.Combine(this.RepoPath, VersionFile.JsonFileName), pathWritten); string actualFileContent = File.ReadAllText(pathWritten); this.Logger.WriteLine(actualFileContent); - VersionOptions actualVersion = VersionFile.GetVersion(this.RepoPath); + VersionOptions actualVersion = this.Context.VersionFile.GetVersion(); Assert.Equal(new Version(expectedVersion), actualVersion.Version.Version); Assert.Equal(expectedPrerelease ?? string.Empty, actualVersion.Version.Prerelease); @@ -128,7 +144,7 @@ public void SetVersion_WritesSimplestFile(string version, string assemblyVersion VersionHeightOffset = versionHeightOffset, Inherit = inherit, }; - string pathWritten = VersionFile.SetVersion(this.RepoPath, versionOptions, includeSchemaProperty: false); + string pathWritten = this.Context.VersionFile.SetVersion(this.RepoPath, versionOptions, includeSchemaProperty: false); string actualFileContent = File.ReadAllText(pathWritten); this.Logger.WriteLine(actualFileContent); @@ -137,18 +153,18 @@ public void SetVersion_WritesSimplestFile(string version, string assemblyVersion } [Fact] - public void SetVersion_PathFilters_ThrowsOutsideOfGitRepo() + public void SetVersion_PathFilters_OutsideGitRepo() { var versionOptions = new VersionOptions { Version = SemanticVersion.Parse("1.2"), - PathFilters = new [] + PathFilters = new[] { new FilterPath("./foo", ""), } }; - Assert.Throws(() => VersionFile.SetVersion(this.RepoPath, versionOptions)); + this.Context.VersionFile.SetVersion(this.RepoPath, versionOptions); } [Fact] @@ -159,7 +175,7 @@ public void SetVersion_PathFilters_DifferentRelativePaths() var versionOptions = new VersionOptions { Version = SemanticVersion.Parse("1.2"), - PathFilters = new [] + PathFilters = new[] { new FilterPath("./foo", "bar"), new FilterPath("/absolute", "bar"), @@ -168,9 +184,10 @@ public void SetVersion_PathFilters_DifferentRelativePaths() var expected = versionOptions.PathFilters.Select(x => x.RepoRelativePath).ToList(); var projectDirectory = Path.Combine(this.RepoPath, "quux"); - VersionFile.SetVersion(projectDirectory, versionOptions); + this.Context.VersionFile.SetVersion(projectDirectory, versionOptions); - var actualVersionOptions = VersionFile.GetVersion(projectDirectory); + using var projectContext = this.CreateGitContext(projectDirectory); + var actualVersionOptions = projectContext.VersionFile.GetVersion(); var actual = actualVersionOptions.PathFilters.Select(x => x.RepoRelativePath).ToList(); Assert.Equal(expected, actual); } @@ -183,25 +200,26 @@ public void SetVersion_PathFilters_InheritRelativePaths() var rootVersionOptions = new VersionOptions { Version = SemanticVersion.Parse("1.2"), - PathFilters = new [] + PathFilters = new[] { new FilterPath("./root-file.txt", ""), new FilterPath("/absolute", ""), } }; - VersionFile.SetVersion(this.RepoPath, rootVersionOptions); + this.Context.VersionFile.SetVersion(this.RepoPath, rootVersionOptions); - var versionOptions =new VersionOptions + var versionOptions = new VersionOptions { Version = SemanticVersion.Parse("1.2"), Inherit = true }; var projectDirectory = Path.Combine(this.RepoPath, "quux"); - VersionFile.SetVersion(projectDirectory, versionOptions); + this.Context.VersionFile.SetVersion(projectDirectory, versionOptions); var expected = rootVersionOptions.PathFilters.Select(x => x.RepoRelativePath).ToList(); - var actualVersionOptions = VersionFile.GetVersion(projectDirectory); + using var projectContext = this.CreateGitContext(projectDirectory); + var actualVersionOptions = projectContext.VersionFile.GetVersion(); var actual = actualVersionOptions.PathFilters.Select(x => x.RepoRelativePath).ToList(); Assert.Equal(expected, actual); } @@ -214,30 +232,31 @@ public void SetVersion_PathFilters_InheritOverride() var rootVersionOptions = new VersionOptions { Version = SemanticVersion.Parse("1.2"), - PathFilters = new [] + PathFilters = new[] { new FilterPath("./root-file.txt", ""), new FilterPath("/absolute", ""), } }; - VersionFile.SetVersion(this.RepoPath, rootVersionOptions); + this.Context.VersionFile.SetVersion(this.RepoPath, rootVersionOptions); var versionOptions = new VersionOptions { Version = SemanticVersion.Parse("1.2"), Inherit = true, - PathFilters = new [] + PathFilters = new[] { new FilterPath("./project-file.txt", "quux"), new FilterPath("/absolute", "quux"), } }; var projectDirectory = Path.Combine(this.RepoPath, "quux"); - VersionFile.SetVersion(projectDirectory, versionOptions); + this.Context.VersionFile.SetVersion(projectDirectory, versionOptions); var expected = versionOptions.PathFilters.Select(x => x.RepoRelativePath).ToList(); - var actualVersionOptions = VersionFile.GetVersion(projectDirectory); + using var projectContext = this.CreateGitContext(projectDirectory); + var actualVersionOptions = projectContext.VersionFile.GetVersion(); var actual = actualVersionOptions.PathFilters.Select(x => x.RepoRelativePath).ToList(); Assert.Equal(expected, actual); } @@ -278,7 +297,7 @@ public void JsonMinification(string full, string minimal) public void GetVersion_CanReadSpecConformantJsonFile() { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.JsonFileName), "{ version: \"1.2-pre\" }"); - VersionOptions actualVersion = VersionFile.GetVersion(this.RepoPath); + VersionOptions actualVersion = this.Context.VersionFile.GetVersion(); Assert.NotNull(actualVersion); Assert.Equal(new Version(1, 2), actualVersion.Version.Version); Assert.Equal("-pre", actualVersion.Version.Prerelease); @@ -288,7 +307,7 @@ public void GetVersion_CanReadSpecConformantJsonFile() public void GetVersion_CanReadSpecConformantTxtFile_SingleLine() { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.TxtFileName), "1.2-pre"); - VersionOptions actualVersion = VersionFile.GetVersion(this.RepoPath); + VersionOptions actualVersion = this.Context.VersionFile.GetVersion(); Assert.NotNull(actualVersion); Assert.Equal(new Version(1, 2), actualVersion.Version.Version); Assert.Equal("-pre", actualVersion.Version.Prerelease); @@ -298,7 +317,7 @@ public void GetVersion_CanReadSpecConformantTxtFile_SingleLine() public void GetVersion_CanReadSpecConformantTxtFile_MultiLine() { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.TxtFileName), "1.2\n-pre"); - VersionOptions actualVersion = VersionFile.GetVersion(this.RepoPath); + VersionOptions actualVersion = this.Context.VersionFile.GetVersion(); Assert.NotNull(actualVersion); Assert.Equal(new Version(1, 2), actualVersion.Version.Version); Assert.Equal("-pre", actualVersion.Version.Prerelease); @@ -308,7 +327,7 @@ public void GetVersion_CanReadSpecConformantTxtFile_MultiLine() public void GetVersion_CanReadSpecConformantTxtFile_MultiLineNoHyphen() { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.TxtFileName), "1.2\npre"); - VersionOptions actualVersion = VersionFile.GetVersion(this.RepoPath); + VersionOptions actualVersion = this.Context.VersionFile.GetVersion(); Assert.NotNull(actualVersion); Assert.Equal(new Version(1, 2), actualVersion.Version.Version); Assert.Equal("-pre", actualVersion.Version.Prerelease); @@ -317,12 +336,13 @@ public void GetVersion_CanReadSpecConformantTxtFile_MultiLineNoHyphen() [Fact] public void GetVersion_Commit() { - Assert.Null(VersionFile.GetVersion((Commit)null)); + Assert.Null(this.Context.VersionFile.GetVersion()); + Assert.False(this.Context.VersionFile.IsVersionDefined()); this.InitializeSourceControl(); this.WriteVersionFile(); - VersionOptions fromCommit = VersionFile.GetVersion(this.Repo.Head.Commits.First()); - VersionOptions fromFile = VersionFile.GetVersion(this.RepoPath); + VersionOptions fromCommit = this.Context.VersionFile.GetVersion(); + VersionOptions fromFile = this.Context.VersionFile.GetVersion(); Assert.NotNull(fromCommit); Assert.Equal(fromFile, fromCommit); } @@ -339,14 +359,14 @@ public void GetVersion_String_FindsNearestFileInAncestorDirectories() var rootVersionSpec = new VersionOptions { Version = SemanticVersion.Parse("1.0") }; var subdirVersionSpec = new VersionOptions { Version = SemanticVersion.Parse("1.1") }; - VersionFile.SetVersion(this.RepoPath, rootVersionSpec); + this.Context.VersionFile.SetVersion(this.RepoPath, rootVersionSpec); string subDirA = Path.Combine(this.RepoPath, "a"); string subDirAB = Path.Combine(subDirA, "b"); string subDirABC = Path.Combine(subDirAB, "c"); Directory.CreateDirectory(subDirABC); - VersionFile.SetVersion(subDirAB, new Version(1, 1)); + this.Context.VersionFile.SetVersion(subDirAB, new Version(1, 1)); this.InitializeSourceControl(); - var commit = this.Repo.Head.Commits.First(); + var commit = this.LibGit2Repository.Head.Commits.First().Sha; this.AssertPathHasVersion(commit, subDirABC, subdirVersionSpec); this.AssertPathHasVersion(commit, subDirAB, subdirVersionSpec); @@ -370,14 +390,14 @@ public void GetVersion_String_FindsNearestFileInAncestorDirectories_WithAssembly }; var subdirVersionSpec = new VersionOptions { Version = SemanticVersion.Parse("11.0") }; - VersionFile.SetVersion(this.RepoPath, rootVersionSpec); + this.Context.VersionFile.SetVersion(this.RepoPath, rootVersionSpec); string subDirA = Path.Combine(this.RepoPath, "a"); string subDirAB = Path.Combine(subDirA, "b"); string subDirABC = Path.Combine(subDirAB, "c"); Directory.CreateDirectory(subDirABC); - VersionFile.SetVersion(subDirAB, subdirVersionSpec); + this.Context.VersionFile.SetVersion(subDirAB, subdirVersionSpec); this.InitializeSourceControl(); - var commit = this.Repo.Head.Commits.First(); + var commit = this.LibGit2Repository.Head.Commits.First().Sha; this.AssertPathHasVersion(commit, subDirABC, subdirVersionSpec); this.AssertPathHasVersion(commit, subDirAB, subdirVersionSpec); @@ -393,7 +413,7 @@ public void GetVersion_ReadReleaseSettings_VersionIncrement() var path = Path.Combine(this.RepoPath, "version.json"); File.WriteAllText(path, json); - var versionOptions = VersionFile.GetVersion(this.RepoPath); + var versionOptions = this.Context.VersionFile.GetVersion(); Assert.NotNull(versionOptions.Release); Assert.NotNull(versionOptions.Release.VersionIncrement); @@ -407,7 +427,7 @@ public void GetVersion_ReadReleaseSettings_FirstUnstableTag() var path = Path.Combine(this.RepoPath, "version.json"); File.WriteAllText(path, json); - var versionOptions = VersionFile.GetVersion(this.RepoPath); + var versionOptions = this.Context.VersionFile.GetVersion(); Assert.NotNull(versionOptions.Release); Assert.NotNull(versionOptions.Release.FirstUnstableTag); @@ -421,7 +441,7 @@ public void GetVersion_ReadReleaseSettings_BranchName() var path = Path.Combine(this.RepoPath, "version.json"); File.WriteAllText(path, json); - var versionOptions = VersionFile.GetVersion(this.RepoPath); + var versionOptions = this.Context.VersionFile.GetVersion(); Assert.NotNull(versionOptions.Release); Assert.NotNull(versionOptions.Release.BranchName); @@ -438,26 +458,26 @@ public void GetVersion_ReadPathFilters() File.WriteAllText(path, json); var repoRelativeBaseDirectory = "."; - var versionOptions = VersionFile.GetVersion(this.RepoPath); + var versionOptions = this.Context.VersionFile.GetVersion(); Assert.NotNull(versionOptions.PathFilters); Assert.Equal(new[] { "/root.txt", "./hello" }, versionOptions.PathFilters.Select(fp => fp.ToPathSpec(repoRelativeBaseDirectory))); } [Fact] - public void GetVersion_ThrowsWithPathFiltersOutsideOfGitRepo() + public void GetVersion_WithPathFiltersOutsideOfGitRepo() { var json = @"{ ""version"" : ""1.2"", ""pathFilters"" : [ ""."" ] }"; var path = Path.Combine(this.RepoPath, "version.json"); File.WriteAllText(path, json); - Assert.Throws(() => VersionFile.GetVersion(this.RepoPath)); + this.Context.VersionFile.GetVersion(); } [Fact] public void GetVersion_String_MissingFile() { - Assert.Null(VersionFile.GetVersion(this.RepoPath)); + Assert.Null(this.Context.VersionFile.GetVersion()); } [Fact] @@ -470,7 +490,7 @@ public void VersionJson_InheritButNoParentFileFound() Inherit = true, Version = SemanticVersion.Parse("14.2"), }); - Assert.Throws(() => VersionFile.GetVersion(this.Repo)); + Assert.Throws(() => this.Context.VersionFile.GetVersion()); } [Fact] @@ -529,63 +549,57 @@ public void VersionJson_Inheritance(bool commitInSourceControl) }, "inheritWithVersion"); - Repository operatingRepo = this.Repo; + VersionOptions GetOption(string path) + { + using var context = this.CreateGitContext(Path.Combine(this.RepoPath, path)); + return context.VersionFile.GetVersion(); + } + + var level1Options = GetOption(string.Empty); + Assert.False(level1Options.Inherit); + + var level2Options = GetOption("foo"); + Assert.Equal(level1.Version.Version.Major, level2Options.Version.Version.Major); + Assert.Equal(level1.Version.Version.Minor, level2Options.Version.Version.Minor); + Assert.Equal(level2.AssemblyVersion.Precision, level2Options.AssemblyVersion.Precision); + Assert.True(level2Options.Inherit); + + var level3Options = GetOption("foo/bar"); + Assert.Equal(level1.Version.Version.Major, level3Options.Version.Version.Major); + Assert.Equal(level1.Version.Version.Minor, level3Options.Version.Version.Minor); + Assert.Equal(level2.AssemblyVersion.Precision, level3Options.AssemblyVersion.Precision); + Assert.Equal(level2.AssemblyVersion.Precision, level3Options.AssemblyVersion.Precision); + Assert.Equal(level3.VersionHeightOffset, level3Options.VersionHeightOffset); + Assert.True(level3Options.Inherit); + + var level2NoInheritOptions = GetOption("noInherit"); + Assert.Equal(level2NoInherit.Version, level2NoInheritOptions.Version); + Assert.Equal(VersionOptions.DefaultVersionPrecision, level2NoInheritOptions.AssemblyVersionOrDefault.PrecisionOrDefault); + Assert.False(level2NoInheritOptions.Inherit); + + var level2InheritButResetVersionOptions = GetOption("inheritWithVersion"); + Assert.Equal(level2InheritButResetVersion.Version, level2InheritButResetVersionOptions.Version); + Assert.True(level2InheritButResetVersionOptions.Inherit); - using (operatingRepo) + if (commitInSourceControl) { - VersionOptions GetOption(string path) => commitInSourceControl ? VersionFile.GetVersion(operatingRepo, path) : VersionFile.GetVersion(Path.Combine(this.RepoPath, path)); - - var level1Options = GetOption(string.Empty); - Assert.False(level1Options.Inherit); - - var level2Options = GetOption("foo"); - Assert.Equal(level1.Version.Version.Major, level2Options.Version.Version.Major); - Assert.Equal(level1.Version.Version.Minor, level2Options.Version.Version.Minor); - Assert.Equal(level2.AssemblyVersion.Precision, level2Options.AssemblyVersion.Precision); - Assert.True(level2Options.Inherit); - - var level3Options = GetOption("foo/bar"); - Assert.Equal(level1.Version.Version.Major, level3Options.Version.Version.Major); - Assert.Equal(level1.Version.Version.Minor, level3Options.Version.Version.Minor); - Assert.Equal(level2.AssemblyVersion.Precision, level3Options.AssemblyVersion.Precision); - Assert.Equal(level2.AssemblyVersion.Precision, level3Options.AssemblyVersion.Precision); - Assert.Equal(level3.VersionHeightOffset, level3Options.VersionHeightOffset); - Assert.True(level3Options.Inherit); - - var level2NoInheritOptions = GetOption("noInherit"); - Assert.Equal(level2NoInherit.Version, level2NoInheritOptions.Version); - Assert.Equal(VersionOptions.DefaultVersionPrecision, level2NoInheritOptions.AssemblyVersionOrDefault.PrecisionOrDefault); - Assert.False(level2NoInheritOptions.Inherit); - - var level2InheritButResetVersionOptions = GetOption("inheritWithVersion"); - Assert.Equal(level2InheritButResetVersion.Version, level2InheritButResetVersionOptions.Version); - Assert.True(level2InheritButResetVersionOptions.Inherit); - - if (commitInSourceControl) - { - int totalCommits = operatingRepo.Head.Commits.Count(); + int totalCommits = this.LibGit2Repository.Head.Commits.Count(); - // The version height should be the same for all those that inherit the version from the base, - // even though the inheriting files were introduced in successive commits. - Assert.Equal(totalCommits, GetVersionHeight(operatingRepo)); - Assert.Equal(totalCommits, GetVersionHeight(operatingRepo, "foo")); - Assert.Equal(totalCommits, GetVersionHeight(operatingRepo, "foo/bar")); + // The version height should be the same for all those that inherit the version from the base, + // even though the inheriting files were introduced in successive commits. + Assert.Equal(totalCommits, this.GetVersionHeight()); + Assert.Equal(totalCommits, this.GetVersionHeight("foo")); + Assert.Equal(totalCommits, this.GetVersionHeight("foo/bar")); - // These either don't inherit, or inherit but reset versions, so the commits were reset. - Assert.Equal(2, GetVersionHeight(operatingRepo, "noInherit")); - Assert.Equal(1, GetVersionHeight(operatingRepo, "inheritWithVersion")); - } + // These either don't inherit, or inherit but reset versions, so the commits were reset. + Assert.Equal(2, this.GetVersionHeight("noInherit")); + Assert.Equal(1, this.GetVersionHeight("inheritWithVersion")); } } - private void AssertPathHasVersion(Commit commit, string absolutePath, VersionOptions expected) + private void AssertPathHasVersion(string committish, string absolutePath, VersionOptions expected) { - var actual = VersionFile.GetVersion(absolutePath); - Assert.Equal(expected, actual); - - // Pass in the repo-relative path to ensure the commit is used as the data source. - string relativePath = absolutePath.Substring(this.RepoPath.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - actual = VersionFile.GetVersion(commit, relativePath); - Assert.Equal(expected, actual); + var actual = this.GetVersionOptions(absolutePath, committish); + Assert.Equal(expected, this.GetVersionOptions(absolutePath, committish)); } } diff --git a/src/NerdBank.GitVersioning.Tests/VersionOracleTests.cs b/src/NerdBank.GitVersioning.Tests/VersionOracleTests.cs index ff421a88..62283a3e 100644 --- a/src/NerdBank.GitVersioning.Tests/VersionOracleTests.cs +++ b/src/NerdBank.GitVersioning.Tests/VersionOracleTests.cs @@ -7,20 +7,45 @@ using Xunit.Abstractions; using Version = System.Version; -public class VersionOracleTests : RepoTestBase +[Trait("Engine", "Managed")] +public class VersionOracleManagedTests : VersionOracleTests +{ + public VersionOracleManagedTests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: false); +} + +[Trait("Engine", "LibGit2")] +public class VersionOracleLibGit2Tests : VersionOracleTests +{ + public VersionOracleLibGit2Tests(ITestOutputHelper logger) + : base(logger) + { + } + + protected override GitContext CreateGitContext(string path, string committish = null) + => GitContext.Create(path, committish, writable: true); +} + +public abstract class VersionOracleTests : RepoTestBase { public VersionOracleTests(ITestOutputHelper logger) : base(logger) { } - private string CommitIdShort => this.Repo.Head.Commits.First().Id.Sha.Substring(0, VersionOptions.DefaultGitCommitIdShortFixedLength); + private string CommitIdShort => this.Context.GitCommitId?.Substring(0, VersionOptions.DefaultGitCommitIdShortFixedLength); [Fact] public void NotRepo() { // Seems safe to assume a temporary path is not a Git directory. - var oracle = VersionOracle.Create(Path.GetTempPath()); + var context = this.CreateGitContext(Path.GetTempPath()); + var oracle = new VersionOracle(context); Assert.Equal(0, oracle.VersionHeight); } @@ -29,13 +54,15 @@ public void Submodule_RecognizedWithCorrectVersion() { using (var expandedRepo = TestUtilities.ExtractRepoArchive("submodules")) { - this.Repo = new Repository(expandedRepo.RepoPath); + this.Context = this.CreateGitContext(expandedRepo.RepoPath); - var oracleA = VersionOracle.Create(Path.Combine(expandedRepo.RepoPath, "a")); + this.Context.RepoRelativeProjectDirectory = "a"; + var oracleA = new VersionOracle(this.Context); Assert.Equal("1.3.1", oracleA.SimpleVersion.ToString()); Assert.Equal("e238b03e75", oracleA.GitCommitIdShort); - var oracleB = VersionOracle.Create(Path.Combine(expandedRepo.RepoPath, "b", "projB")); + this.Context.RepoRelativeProjectDirectory = Path.Combine("b", "projB"); + var oracleB = new VersionOracle(this.Context); Assert.Equal("2.5.2", oracleB.SimpleVersion.ToString()); Assert.Equal("3ea7f010c3", oracleB.GitCommitIdShort); } @@ -50,7 +77,7 @@ public void MajorMinorPrereleaseBuildMetadata() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); Assert.Equal("7.8", oracle.MajorMinorVersion.ToString()); Assert.Equal(oracle.VersionHeight, oracle.BuildNumber); @@ -70,7 +97,7 @@ public void MajorMinorBuildPrereleaseBuildMetadata() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); Assert.Equal("7.8", oracle.MajorMinorVersion.ToString()); Assert.Equal(9, oracle.BuildNumber); Assert.Equal(oracle.VersionHeight + oracle.VersionHeightOffset, oracle.Version.Revision); @@ -99,21 +126,23 @@ public void VersionHeightResetsWithVersionSpecChanges(string initial, string nex this.InitializeSourceControl(); this.AddCommits(10); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); Assert.Equal(11, oracle.VersionHeight); - Assert.Equal(11, this.GetVersionHeight(this.Repo.Head)); options.Version = SemanticVersion.Parse(next); this.WriteVersionFile(options); - oracle = VersionOracle.Create(this.RepoPath); + this.SetContextToHead(); + oracle = new VersionOracle(this.Context); Assert.Equal(1, oracle.VersionHeight); - Assert.Equal(1, this.GetVersionHeight(this.Repo.Head)); - foreach (var commit in this.Repo.Head.Commits) + if (this.Context is Nerdbank.GitVersioning.LibGit2.LibGit2Context libgit2Context) { - var versionFromId = this.GetIdAsVersion(commit); - Assert.Contains(commit, this.Repo.GetCommitsFromVersion(versionFromId)); + foreach (var commit in libgit2Context.Repository.Head.Commits) + { + var versionFromId = this.GetVersion(committish: commit.Sha); + Assert.Contains(commit, Nerdbank.GitVersioning.LibGit2.LibGit2GitExtensions.GetCommitsFromVersion(libgit2Context, versionFromId)); + } } } @@ -127,7 +156,7 @@ public void HeightInPrerelease() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); Assert.Equal("7.8", oracle.MajorMinorVersion.ToString()); Assert.Equal(9, oracle.BuildNumber); Assert.Equal(-1, oracle.Version.Revision); @@ -148,7 +177,7 @@ public void HeightInBuildMetadata() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); Assert.Equal("7.8", oracle.MajorMinorVersion.ToString()); Assert.Equal(9, oracle.BuildNumber); Assert.Equal(oracle.VersionHeight + oracle.VersionHeightOffset, oracle.Version.Revision); @@ -176,7 +205,7 @@ public void SemVer1PrereleaseConversion(string semVer2, string semVer1) }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = true; Assert.Equal(semVer1, oracle.SemVer1); } @@ -192,7 +221,7 @@ public void SemVer1PrereleaseConversionPadding() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = true; Assert.Equal("7.8.9-foo-025", oracle.SemVer1); } @@ -206,7 +235,7 @@ public void SemVerStableNonPublicVersion() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = false; Assert.Matches(@"^2.3.1-[^g]{10}$", oracle.SemVer1); Assert.Matches(@"^2.3.1-g[a-f0-9]{10}$", oracle.SemVer2); @@ -224,7 +253,7 @@ public void SemVerStableNonPublicVersionShortened() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = false; Assert.Matches(@"^2.3.1-[^g]{7}$", oracle.SemVer1); Assert.Matches(@"^2.3.1-g[a-f0-9]{7}$", oracle.SemVer2); @@ -242,7 +271,7 @@ public void DefaultNuGetPackageVersionIsSemVer1PublicRelease() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = true; Assert.Equal($"7.8.9-foo-25", oracle.NuGetPackageVersion); } @@ -257,7 +286,7 @@ public void DefaultNuGetPackageVersionIsSemVer1NonPublicRelease() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = false; Assert.Equal($"7.8.9-foo-25-g{this.CommitIdShort}", oracle.NuGetPackageVersion); } @@ -272,7 +301,7 @@ public void NpmPackageVersionIsSemVer2() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = true; Assert.Equal("7.8.9-foo.25", oracle.NpmPackageVersion); } @@ -290,7 +319,7 @@ public void CanSetSemVer2ForNuGetPackageVersionPublicRelease() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = true; Assert.Equal($"7.8.9-foo.25", oracle.NuGetPackageVersion); } @@ -308,7 +337,7 @@ public void CanSetSemVer2ForNuGetPackageVersionNonPublicRelease() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = false; Assert.Equal($"7.8.9-foo.25.g{this.CommitIdShort}", oracle.NuGetPackageVersion); } @@ -327,7 +356,7 @@ public void CanSetGitCommitIdPrefixNonPublicRelease() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); oracle.PublicRelease = false; Assert.Equal($"7.8.9-foo.25.git{this.CommitIdShort}", oracle.NuGetPackageVersion); } @@ -353,19 +382,17 @@ public void CanUseGitProjectRelativePathWithGitRepoRoot() this.InitializeSourceControl(); // Check Root Version. Root version will be used - var oracle = VersionOracle.Create(this.RepoPath, this.RepoPath, null, null); + var oracle = new VersionOracle(this.Context); Assert.Equal("1.1", oracle.MajorMinorVersion.ToString()); // Check ChildProject with projectRelativeDir, with version file. Child project version will be used. - oracle = VersionOracle.Create(childProjectAbsoluteDir, this.RepoPath, null, null, childProjectRelativeDir); - Assert.Equal("2.2", oracle.MajorMinorVersion.ToString()); - - // Check ChildProject withOUT projectRelativeDir, with Version file. Child project version will be used. - oracle = VersionOracle.Create(childProjectAbsoluteDir, this.RepoPath); + this.Context.RepoRelativeProjectDirectory = childProjectRelativeDir; + oracle = new VersionOracle(this.Context); Assert.Equal("2.2", oracle.MajorMinorVersion.ToString()); // Check ChildProject withOUT Version file. Root version will be used. - oracle = VersionOracle.Create(Path.Combine(this.RepoPath, "otherChildProject"), this.RepoPath, null, null, "otherChildProject"); + this.Context.RepoRelativeProjectDirectory = "otherChildProject"; + oracle = new VersionOracle(this.Context); Assert.Equal("1.1", oracle.MajorMinorVersion.ToString()); } @@ -374,7 +401,7 @@ public void VersionJsonWithoutVersion() { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.JsonFileName), "{}"); this.InitializeSourceControl(); - var oracle = VersionOracle.Create(this.RepoPath); + var oracle = new VersionOracle(this.Context); Assert.Equal(0, oracle.Version.Major); Assert.Equal(0, oracle.Version.Minor); } @@ -384,8 +411,8 @@ public void VersionJsonWithSingleIntegerForVersion() { File.WriteAllText(Path.Combine(this.RepoPath, VersionFile.JsonFileName), @"{""version"":""3""}"); this.InitializeSourceControl(); - var ex = Assert.Throws(() => VersionOracle.Create(this.RepoPath)); - Assert.Contains(this.Repo.Head.Commits.First().Sha, ex.Message); + var ex = Assert.Throws(() => new VersionOracle(this.Context)); + Assert.Contains(this.Context.GitCommitId, ex.Message); Assert.Contains("\"3\"", ex.InnerException.Message); this.Logger.WriteLine(ex.ToString()); } @@ -399,36 +426,27 @@ public void Worktree_Support() }; this.WriteVersionFile(workingCopyVersion); this.InitializeSourceControl(); - var oracleOriginal = VersionOracle.Create(this.RepoPath); + var oracleOriginal = new VersionOracle(this.Context); this.AddCommits(); string workTreePath = this.CreateDirectoryForNewRepo(); Directory.Delete(workTreePath); - this.Repo.Worktrees.Add("HEAD~1", "myworktree", workTreePath, isLocked: false); - var oracleWorkTree = VersionOracle.Create(workTreePath); + this.LibGit2Repository.Worktrees.Add("HEAD~1", "myworktree", workTreePath, isLocked: false); + var context = this.CreateGitContext(workTreePath); + var oracleWorkTree = new VersionOracle(context); Assert.Equal(oracleOriginal.Version, oracleWorkTree.Version); } - [Fact] - public void GetHeight_EmptyRepo() - { - this.InitializeSourceControl(); - - Branch head = this.Repo.Head; - Assert.Throws(() => head.GetHeight()); - Assert.Throws(() => head.GetHeight(c => true)); - } - [Fact] public void GetVersionHeight_Test() { this.InitializeSourceControl(); - var first = this.Repo.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - var second = this.Repo.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var first = this.LibGit2Repository.Commit("First", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + var second = this.LibGit2Repository.Commit("Second", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); this.WriteVersionFile(); - var third = this.Repo.Commit("Third", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); - Assert.Equal(2, this.GetVersionHeight(this.Repo.Head)); + var third = this.LibGit2Repository.Commit("Third", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + Assert.Equal(2, this.GetVersionHeight()); } [Fact] @@ -440,19 +458,18 @@ public void GetVersionHeight_VersionJsonHasUnrelatedHistory() string versionJsonPath = Path.Combine(this.RepoPath, "version.json"); File.WriteAllText(versionJsonPath, @"{ ""unrelated"": false }"); Assert.Equal(0, this.GetVersionHeight()); // exercise code that handles the file not yet checked in. - Commands.Stage(this.Repo, versionJsonPath); - this.Repo.Commit("Add unrelated version.json file.", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, versionJsonPath); + this.LibGit2Repository.Commit("Add unrelated version.json file.", this.Signer, this.Signer); Assert.Equal(0, this.GetVersionHeight()); // exercise code that handles a checked in file. // And now the repo has decided to use this package. this.WriteVersionFile(); - Assert.Equal(1, this.GetVersionHeight(this.Repo.Head)); Assert.Equal(1, this.GetVersionHeight()); // Also emulate case of where the related version.json was just changed to conform, // but not yet checked in. - this.Repo.Reset(ResetMode.Mixed, this.Repo.Head.Tip.Parents.Single()); + this.LibGit2Repository.Reset(ResetMode.Mixed, this.LibGit2Repository.Head.Tip.Parents.Single()); Assert.Equal(0, this.GetVersionHeight()); } @@ -467,8 +484,8 @@ public void GetVersionHeight_VersionJsonHasParsingErrorsInHistory() string versionJsonPath = Path.Combine(this.RepoPath, "version.json"); File.WriteAllText(versionJsonPath, @"{ ""version"": ""1.0"""); // no closing curly brace for parsing error Assert.Equal(0, this.GetVersionHeight()); - Commands.Stage(this.Repo, versionJsonPath); - this.Repo.Commit("Add broken version.json file.", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, versionJsonPath); + this.LibGit2Repository.Commit("Add broken version.json file.", this.Signer, this.Signer); Assert.Equal(0, this.GetVersionHeight()); // Now fix it. @@ -476,7 +493,7 @@ public void GetVersionHeight_VersionJsonHasParsingErrorsInHistory() Assert.Equal(1, this.GetVersionHeight()); // And emulate fixing it without having checked in yet. - this.Repo.Reset(ResetMode.Mixed, this.Repo.Head.Tip.Parents.Single()); + this.LibGit2Repository.Reset(ResetMode.Mixed, this.LibGit2Repository.Head.Tip.Parents.Single()); Assert.Equal(0, this.GetVersionHeight()); } @@ -515,15 +532,15 @@ public void GetVersionHeight_IncludeFilter(string includeFilter) // Expect commit outside of project tree to not affect version height var otherFilePath = Path.Combine(this.RepoPath, "my-file.txt"); File.WriteAllText(otherFilePath, "hello"); - Commands.Stage(this.Repo, otherFilePath); - this.Repo.Commit("Add other file outside of project root", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, otherFilePath); + this.LibGit2Repository.Commit("Add other file outside of project root", this.Signer, this.Signer); Assert.Equal(1, this.GetVersionHeight(relativeDirectory)); // Expect commit inside project tree to affect version height var containedFilePath = Path.Combine(this.RepoPath, relativeDirectory, "another-file.txt"); File.WriteAllText(containedFilePath, "hello"); - Commands.Stage(this.Repo, containedFilePath); - this.Repo.Commit("Add file within project root", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, containedFilePath); + this.LibGit2Repository.Commit("Add file within project root", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); } @@ -547,25 +564,25 @@ public void GetVersionHeight_IncludeExcludeFilter() // Commit touching excluded path does not affect version height var ignoredFilePath = Path.Combine(this.RepoPath, relativeDirectory, "ignore.txt"); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Add excluded file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Add excluded file", this.Signer, this.Signer); Assert.Equal(1, this.GetVersionHeight(relativeDirectory)); // Commit touching both excluded and included path does affect height var includedFilePath = Path.Combine(this.RepoPath, relativeDirectory, "another-file.txt"); File.WriteAllText(includedFilePath, "hello"); File.WriteAllText(ignoredFilePath, "changed"); - Commands.Stage(this.Repo, includedFilePath); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, includedFilePath); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Change both excluded and included file", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); // Commit touching excluded directory does not affect version height var fileInExcludedDirPath = Path.Combine(this.RepoPath, relativeDirectory, "excluded-dir", "ignore.txt"); Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); File.WriteAllText(fileInExcludedDirPath, "hello"); - Commands.Stage(this.Repo, fileInExcludedDirPath); - this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, fileInExcludedDirPath); + this.LibGit2Repository.Commit("Add file to excluded dir", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); } @@ -588,25 +605,25 @@ public void GetVersionHeight_IncludeExcludeFilter_NoProjectDirectory() var ignoredFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "ignore.txt"); Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Add excluded file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Add excluded file", this.Signer, this.Signer); Assert.Equal(1, this.GetVersionHeight()); // Commit touching both excluded and included path does affect height var includedFilePath = Path.Combine(this.RepoPath, "some-sub-dir", "another-file.txt"); File.WriteAllText(includedFilePath, "hello"); File.WriteAllText(ignoredFilePath, "changed"); - Commands.Stage(this.Repo, includedFilePath); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, includedFilePath); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Change both excluded and included file", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight()); // Commit touching excluded directory does not affect version height var fileInExcludedDirPath = Path.Combine(this.RepoPath, "excluded-dir", "ignore.txt"); Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); File.WriteAllText(fileInExcludedDirPath, "hello"); - Commands.Stage(this.Repo, fileInExcludedDirPath); - this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, fileInExcludedDirPath); + this.LibGit2Repository.Commit("Add file to excluded dir", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight()); } @@ -627,8 +644,8 @@ public void GetVersionHeight_AddingExcludeDoesNotLowerHeight(string excludePathF var ignoredFilePath = Path.Combine(this.RepoPath, "excluded-dir", "ignore.txt"); Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Add file which will later be excluded", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Add file which will later be excluded", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); versionData.PathFilters = new[] { new FilterPath(excludePathFilter, relativeDirectory), }; @@ -637,8 +654,8 @@ public void GetVersionHeight_AddingExcludeDoesNotLowerHeight(string excludePathF // Committing a change to an ignored file does not increment the version height File.WriteAllText(ignoredFilePath, "changed"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Change now excluded file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Change now excluded file", this.Signer, this.Signer); Assert.Equal(3, this.GetVersionHeight(relativeDirectory)); } @@ -657,15 +674,15 @@ public void GetVersionHeight_IncludeRoot() // Expect commit outside of project tree to affect version height var otherFilePath = Path.Combine(this.RepoPath, "my-file.txt"); File.WriteAllText(otherFilePath, "hello"); - Commands.Stage(this.Repo, otherFilePath); - this.Repo.Commit("Add other file outside of project root", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, otherFilePath); + this.LibGit2Repository.Commit("Add other file outside of project root", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); // Expect commit inside project tree to affect version height var containedFilePath = Path.Combine(this.RepoPath, relativeDirectory, "another-file.txt"); File.WriteAllText(containedFilePath, "hello"); - Commands.Stage(this.Repo, containedFilePath); - this.Repo.Commit("Add file within project root", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, containedFilePath); + this.LibGit2Repository.Commit("Add file within project root", this.Signer, this.Signer); Assert.Equal(3, this.GetVersionHeight(relativeDirectory)); } @@ -689,16 +706,16 @@ public void GetVersionHeight_IncludeRootExcludeSome() var ignoredFilePath = Path.Combine(this.RepoPath, "excluded-dir", "my-file.txt"); Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Add other file to excluded directory", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Add other file to excluded directory", this.Signer, this.Signer); Assert.Equal(1, this.GetVersionHeight(relativeDirectory)); // Expect commit within another directory to affect version height var otherFilePath = Path.Combine(this.RepoPath, "another-dir", "another-file.txt"); Directory.CreateDirectory(Path.GetDirectoryName(otherFilePath)); File.WriteAllText(otherFilePath, "hello"); - Commands.Stage(this.Repo, otherFilePath); - this.Repo.Commit("Add file within project root", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, otherFilePath); + this.LibGit2Repository.Commit("Add file within project root", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); } @@ -721,8 +738,8 @@ public void GetVersionHeight_ProjectDirectoryDifferentToVersionJsonDirectory() var ignoredFilePath = Path.Combine(this.RepoPath, "other-dir", "my-file.txt"); Directory.CreateDirectory(Path.GetDirectoryName(ignoredFilePath)); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Add file to other directory", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Add file to other directory", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); } @@ -746,37 +763,56 @@ public void GetVersionHeight_ProjectDirectoryIsMoved() // Commit touching excluded path does not affect version height var ignoredFilePath = Path.Combine(this.RepoPath, relativeDirectory, "ignore.txt"); File.WriteAllText(ignoredFilePath, "hello"); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Add excluded file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Add excluded file", this.Signer, this.Signer); Assert.Equal(1, this.GetVersionHeight(relativeDirectory)); // Commit touching both excluded and included path does affect height var includedFilePath = Path.Combine(this.RepoPath, relativeDirectory, "another-file.txt"); File.WriteAllText(includedFilePath, "hello"); File.WriteAllText(ignoredFilePath, "changed"); - Commands.Stage(this.Repo, includedFilePath); - Commands.Stage(this.Repo, ignoredFilePath); - this.Repo.Commit("Change both excluded and included file", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, includedFilePath); + Commands.Stage(this.LibGit2Repository, ignoredFilePath); + this.LibGit2Repository.Commit("Change both excluded and included file", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); // Commit touching excluded directory does not affect version height var fileInExcludedDirPath = Path.Combine(this.RepoPath, relativeDirectory, "excluded-dir", "ignore.txt"); Directory.CreateDirectory(Path.GetDirectoryName(fileInExcludedDirPath)); File.WriteAllText(fileInExcludedDirPath, "hello"); - Commands.Stage(this.Repo, fileInExcludedDirPath); - this.Repo.Commit("Add file to excluded dir", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, fileInExcludedDirPath); + this.LibGit2Repository.Commit("Add file to excluded dir", this.Signer, this.Signer); Assert.Equal(2, this.GetVersionHeight(relativeDirectory)); // Rename the project directory Directory.Move(Path.Combine(this.RepoPath, relativeDirectory), Path.Combine(this.RepoPath, "new-project-dir")); - Commands.Stage(this.Repo, relativeDirectory); - Commands.Stage(this.Repo, "new-project-dir"); - this.Repo.Commit("Move project directory", this.Signer, this.Signer); + Commands.Stage(this.LibGit2Repository, relativeDirectory); + Commands.Stage(this.LibGit2Repository, "new-project-dir"); + this.LibGit2Repository.Commit("Move project directory", this.Signer, this.Signer); // Version is reset as project directory cannot be find in the ancestor commit Assert.Equal(1, this.GetVersionHeight("new-project-dir")); } + [Fact] + public void GitCommitIdShort() + { + this.WriteVersionFile(new VersionOptions { Version = SemanticVersion.Parse("1.2"), GitCommitIdShortAutoMinimum = 4 }); + this.InitializeSourceControl(); + this.AddCommits(1); + var oracle = new VersionOracle(this.Context); + + if (this.Context is Nerdbank.GitVersioning.LibGit2.LibGit2Context) + { + // I'm not sure why libgit2 returns 7 as the minimum length when clearly a two commit repo would need fewer. + Assert.Equal(7, oracle.GitCommitIdShort.Length); + } + else + { + Assert.Equal(4, oracle.GitCommitIdShort.Length); + } + } + [Fact(Skip = "Slow test")] public void GetVersionHeight_VeryLongHistory() { diff --git a/src/NerdBank.GitVersioning/CloudBuild.cs b/src/NerdBank.GitVersioning/CloudBuild.cs index ff1c13db..c1025aac 100644 --- a/src/NerdBank.GitVersioning/CloudBuild.cs +++ b/src/NerdBank.GitVersioning/CloudBuild.cs @@ -3,7 +3,6 @@ using System; using System.Linq; using CloudBuildServices; - using NerdBank.GitVersioning.CloudBuildServices; /// /// Provides access to cloud build providers. diff --git a/src/NerdBank.GitVersioning/CloudBuildServices/GitHubActions.cs b/src/NerdBank.GitVersioning/CloudBuildServices/GitHubActions.cs index 55eac257..85adfffe 100644 --- a/src/NerdBank.GitVersioning/CloudBuildServices/GitHubActions.cs +++ b/src/NerdBank.GitVersioning/CloudBuildServices/GitHubActions.cs @@ -1,4 +1,4 @@ -namespace NerdBank.GitVersioning.CloudBuildServices +namespace Nerdbank.GitVersioning.CloudBuildServices { using System; using System.Collections.Generic; diff --git a/src/NerdBank.GitVersioning/FilterPath.cs b/src/NerdBank.GitVersioning/FilterPath.cs index f4acbb5a..52edad43 100644 --- a/src/NerdBank.GitVersioning/FilterPath.cs +++ b/src/NerdBank.GitVersioning/FilterPath.cs @@ -17,6 +17,11 @@ public class FilterPath /// public bool IsExclude { get; } + /// + /// if this represents an include filter. + /// + public bool IsInclude => !this.IsExclude; + /// /// Path relative to the repository root that this represents. /// Directories are delimited with forward slashes. @@ -168,6 +173,32 @@ public bool Excludes(string repoRelativePath, bool ignoreCase) stringComparison); } + /// + /// Determines if should be included by this . + /// + /// Forward-slash delimited path (repo relative). + /// + /// Whether paths should be compared case insensitively. + /// Should be the 'core.ignorecase' config value for the repository. + /// + /// + /// True if this is an including filter that matches + /// , otherwise false. + /// + public bool Includes(string repoRelativePath, bool ignoreCase) + { + if (repoRelativePath is null) + throw new ArgumentNullException(nameof(repoRelativePath)); + + if (!this.IsInclude) return false; + if (this.IsRoot) return true; + + var stringComparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return this.RepoRelativePath.Equals(repoRelativePath, stringComparison) || + repoRelativePath.StartsWith(this.RepoRelativePath + "/", + stringComparison); + } + private static (int dirsToAscend, StringBuilder result) GetRelativePath(string path, string relativeTo) { var pathParts = path.Split('/'); @@ -224,5 +255,11 @@ public string ToPathSpec(string repoRelativeBaseDirectory) return pathSpec.ToString(); } + + /// + public override string ToString() + { + return this.RepoRelativePath; + } } } diff --git a/src/NerdBank.GitVersioning/GitContext.cs b/src/NerdBank.GitVersioning/GitContext.cs new file mode 100644 index 00000000..06f3380e --- /dev/null +++ b/src/NerdBank.GitVersioning/GitContext.cs @@ -0,0 +1,300 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Validation; + +namespace Nerdbank.GitVersioning +{ + /// + /// Represents a location and commit within a git repo and provides access to some version-related git activities. + /// + public abstract class GitContext : IDisposable + { + /// + /// The 0.0 semver. + /// + private protected static readonly SemanticVersion SemVer0 = SemanticVersion.Parse("0.0"); + + /// + /// The 0.0 version. + /// + private protected static readonly Version Version0 = new Version(0, 0); + + /// + /// Maximum allowable value for the + /// and components. + /// + private protected const ushort MaximumBuildNumberOrRevisionComponent = 0xfffe; + + private string repoRelativeProjectDirectory; + + /// Initializes a new instance of the class. + /// The absolute path to the root of the working tree. + /// The path to the .git folder. + protected GitContext(string workingTreePath, string? dotGitPath) + { + this.WorkingTreePath = workingTreePath; + this.repoRelativeProjectDirectory = string.Empty; + this.DotGitPath = dotGitPath; + } + + /// + /// Gets the absolute path to the base directory of the git working tree. + /// + public string WorkingTreePath { get; } + + /// + /// Gets the path to the directory to read version information from, relative to the . + /// + public string RepoRelativeProjectDirectory + { + get => this.repoRelativeProjectDirectory; + set + { + Requires.NotNull(value, nameof(value)); + Requires.Argument(!Path.IsPathRooted(value), nameof(value), "Path must be relative to " + nameof(this.WorkingTreePath) + "."); + this.repoRelativeProjectDirectory = value; + } + } + + /// + /// Gets the absolute path to the directory to read version information from. + /// + public string AbsoluteProjectDirectory => Path.Combine(this.WorkingTreePath, this.RepoRelativeProjectDirectory); + + /// + /// Gets an instance of that will read version information from the context identified by this instance. + /// + public abstract VersionFile VersionFile { get; } + + /// + /// Gets a value indicating whether a git repository was found at ; + /// + public bool IsRepository => this.DotGitPath is object; + + /// + /// Gets the full SHA-1 id of the commit to be read. + /// + public abstract string? GitCommitId { get; } + + /// + /// Gets a value indicating whether refers to the commit at HEAD. + /// + public abstract bool IsHead { get; } + + /// + /// Gets a value indicating whether the repo is a shallow repo. + /// + public bool IsShallow => this.DotGitPath is object && File.Exists(Path.Combine(this.DotGitPath, "shallow")); + + /// + /// Gets the date that the commit identified by was created. + /// + public abstract DateTimeOffset? GitCommitDate { get; } + + /// + /// Gets the canonical name for HEAD's position (e.g. refs/heads/master) + /// + public abstract string? HeadCanonicalName { get; } + + /// + /// Gets the path to the .git folder. + /// + protected string? DotGitPath { get; } + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Creates a context for reading/writing version information at a given path and committish. + /// + /// The path to a directory for which version information is required. + /// The SHA-1 or ref for a git commit. + /// if mutating the git repository may be required; otherwise. + /// + public static GitContext Create(string path, string? committish = null, bool writable = false) + { + if (TryFindGitPaths(path, out string? gitDirectory, out string? workingTreeDirectory, out string? workingTreeRelativePath)) + { + GitContext result = writable + ? (GitContext)new LibGit2.LibGit2Context(workingTreeDirectory, gitDirectory, committish) + : new Managed.ManagedGitContext(workingTreeDirectory, gitDirectory, committish); + result.RepoRelativeProjectDirectory = workingTreeRelativePath; + return result; + } + else + { + // Consider the working tree to be the entire volume. + string workingTree = path; + string? candidate; + while ((candidate = Path.GetDirectoryName(workingTree)) is object) + { + workingTree = candidate; + } + + return new NoGitContext(workingTree) + { + RepoRelativeProjectDirectory = path.Substring(workingTree.Length), + }; + } + } + + /// + /// Sets the context to represent a particular git commit. + /// + /// Any committish string (e.g. commit id, branch, tag). + /// if the string was successfully parsed into a commit; otherwise. + public abstract bool TrySelectCommit(string committish); + + /// + /// Adds a tag with the given name to the commit identified by . + /// + /// The name of the tag. + /// May be thrown if the context was created without specifying write access was required. + /// Thrown if is . + public abstract void ApplyTag(string name); + + /// + /// Adds the specified path to the stage for the working tree. + /// + /// The path to be staged. + /// May be thrown if the context was created without specifying write access was required. + public abstract void Stage(string path); + + /// + /// Gets the shortest string that uniquely identifies the . + /// + /// A minimum length. + /// A string that is at least in length but may be more as required to uniquely identify the git object identified by . + public abstract string GetShortUniqueCommitId(int minLength); + + internal abstract int CalculateVersionHeight(VersionOptions? committedVersion, VersionOptions? workingVersion); + + internal abstract Version GetIdAsVersion(VersionOptions? committedVersion, VersionOptions? workingVersion, int versionHeight); + + internal string GetRepoRelativePath(string absolutePath) + { + var repoRoot = this.WorkingTreePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (!absolutePath.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Path '{absolutePath}' is not within repository '{repoRoot}'", nameof(absolutePath)); + } + + return absolutePath.Substring(repoRoot.Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + /// + /// Gets a value indicating whether the version file has changed in the working tree. + /// + /// + /// The commited . + /// + /// + /// The working version of . + /// + /// if the version file is dirty; otherwise. + protected static bool IsVersionFileChangedInWorkingTree(VersionOptions? committedVersion, VersionOptions? workingVersion) + { + if (workingVersion is object) + { + return !EqualityComparer.Default.Equals(workingVersion, committedVersion); + } + + // A missing working version is a change only if it was previously committed. + return committedVersion is object; + } + + private protected static bool TryFindGitPaths(string path, [NotNullWhen(true)] out string? gitDirectory, [NotNullWhen(true)] out string? workingTreeDirectory, [NotNullWhen(true)] out string? workingTreeRelativePath) + { + path = Path.GetFullPath(path); + var gitDirs = FindGitDir(path); + if (gitDirs is null) + { + gitDirectory = null; + workingTreeDirectory = null; + workingTreeRelativePath = null; + return false; + } + + gitDirectory = gitDirs.Value.GitDirectory; + workingTreeDirectory = gitDirs.Value.WorkingTreeDirectory; + workingTreeRelativePath = path.Substring(gitDirs.Value.WorkingTreeDirectory.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return true; + } + + private protected static void FindGitPaths(string path, out string gitDirectory, out string workingTreeDirectory, out string workingTreeRelativePath) + { + if (TryFindGitPaths(path, out string? gitDirectoryLocal, out string? workingTreeDirectoryLocal, out string? workingTreeRelativePathLocal)) + { + gitDirectory = gitDirectoryLocal; + workingTreeDirectory = workingTreeDirectoryLocal; + workingTreeRelativePath = workingTreeRelativePathLocal; + } + else + { + throw new ArgumentException("Path is not within a git directory.", nameof(path)); + } + } + + /// + protected virtual void Dispose(bool disposing) + { + } + + /// + /// Searches a path and its ancestors for a directory with a .git subdirectory. + /// + /// The absolute path to start the search from. + /// The path to the .git folder and working tree, or if not found. + private static (string GitDirectory, string WorkingTreeDirectory)? FindGitDir(string path) + { + string? startingDir = path; + while (startingDir is object) + { + var dirOrFilePath = Path.Combine(startingDir, ".git"); + if (Directory.Exists(dirOrFilePath)) + { + return (dirOrFilePath, Path.GetDirectoryName(dirOrFilePath)!); + } + else if (File.Exists(dirOrFilePath)) + { + string? relativeGitDirPath = ReadGitDirFromFile(dirOrFilePath); + if (!string.IsNullOrWhiteSpace(relativeGitDirPath)) + { + var fullGitDirPath = Path.GetFullPath(Path.Combine(startingDir, relativeGitDirPath)); + if (Directory.Exists(fullGitDirPath)) + { + return (fullGitDirPath, Path.GetDirectoryName(dirOrFilePath)!); + } + } + } + + startingDir = Path.GetDirectoryName(startingDir); + } + + return null; + } + + private static string? ReadGitDirFromFile(string fileName) + { + const string expectedPrefix = "gitdir: "; + var firstLineOfFile = File.ReadLines(fileName).FirstOrDefault(); + if (firstLineOfFile?.StartsWith(expectedPrefix) ?? false) + { + return firstLineOfFile.Substring(expectedPrefix.Length); // strip off the prefix, leaving just the path + } + + return null; + } + } +} diff --git a/src/NerdBank.GitVersioning/GitException.cs b/src/NerdBank.GitVersioning/GitException.cs new file mode 100644 index 00000000..72802b6e --- /dev/null +++ b/src/NerdBank.GitVersioning/GitException.cs @@ -0,0 +1,89 @@ +#nullable enable + +using System; +using System.Runtime.Serialization; + +namespace Nerdbank.GitVersioning +{ + /// + /// The exception which is thrown by the managed Git layer. + /// + [Serializable] + public class GitException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public GitException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + public GitException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the with an + /// error message and an inner message. + /// + /// + /// A message which describes the error. + /// + /// + /// The which caused this exception to be thrown. + /// + public GitException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class. + /// + protected GitException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + this.ErrorCode = (ErrorCodes)info.GetUInt32(nameof(this.ErrorCode)); + this.iSShallowClone = info.GetBoolean(nameof(this.iSShallowClone)); + } + + /// + /// Gets the error code for this exception. + /// + public ErrorCodes ErrorCode { get; set; } + + /// + /// Gets a value indicating whether the exception was thrown from a shallow clone. + /// + public bool iSShallowClone { get; set; } + + /// + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + info.AddValue(nameof(this.ErrorCode), (int)this.ErrorCode); + info.AddValue(nameof(this.iSShallowClone), this.iSShallowClone); + } + + /// + /// Describes specific error conditions that may warrant branching code paths. + /// + public enum ErrorCodes + { + /// + /// No error code was specified. + /// + Unspecified = 0, + + /// + /// An object could not be found. + /// + ObjectNotFound, + } + } +} diff --git a/src/NerdBank.GitVersioning/ICloudBuild.cs b/src/NerdBank.GitVersioning/ICloudBuild.cs index 9e8eb00f..df8a69fa 100644 --- a/src/NerdBank.GitVersioning/ICloudBuild.cs +++ b/src/NerdBank.GitVersioning/ICloudBuild.cs @@ -1,4 +1,6 @@ -namespace Nerdbank.GitVersioning +#nullable enable + +namespace Nerdbank.GitVersioning { using System.Collections.Generic; using System.IO; @@ -21,17 +23,17 @@ public interface ICloudBuild /// /// Gets the branch being built by a cloud build, if applicable. /// - string BuildingBranch { get; } + string? BuildingBranch { get; } /// /// Gets the tag being built by a cloud build, if applicable. /// - string BuildingTag { get; } + string? BuildingTag { get; } /// /// Gets the git commit ID being built by a cloud build, if applicable. /// - string GitCommitId { get; } + string? GitCommitId { get; } /// /// Sets the build number for the cloud build, if supported. @@ -40,7 +42,7 @@ public interface ICloudBuild /// An optional redirection for what should be written to the standard out stream. /// An optional redirection for what should be written to the standard error stream. /// A dictionary of environment/build variables that the caller should set to update the environment to match the new settings. - IReadOnlyDictionary SetCloudBuildNumber(string buildNumber, TextWriter stdout, TextWriter stderr); + IReadOnlyDictionary SetCloudBuildNumber(string buildNumber, TextWriter? stdout, TextWriter? stderr); /// /// Sets a cloud build variable, if supported. @@ -50,6 +52,6 @@ public interface ICloudBuild /// An optional redirection for what should be written to the standard out stream. /// An optional redirection for what should be written to the standard error stream. /// A dictionary of environment/build variables that the caller should set to update the environment to match the new settings. - IReadOnlyDictionary SetCloudBuildVariable(string name, string value, TextWriter stdout, TextWriter stderr); + IReadOnlyDictionary SetCloudBuildVariable(string name, string value, TextWriter? stdout, TextWriter? stderr); } } diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs new file mode 100644 index 00000000..aabe0ac2 --- /dev/null +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs @@ -0,0 +1,177 @@ +#nullable enable + +using System; +using System.Diagnostics; +using LibGit2Sharp; +using Validation; + +namespace Nerdbank.GitVersioning.LibGit2 +{ + /// + /// A git context implemented in terms of LibGit2Sharp. + /// + [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + public class LibGit2Context : GitContext + { + internal LibGit2Context(string workingTreeDirectory, string dotGitPath, string? committish = null) + : base(workingTreeDirectory, dotGitPath) + { + this.Repository = OpenGitRepo(workingTreeDirectory, useDefaultConfigSearchPaths: true); + if (this.Repository.Info.WorkingDirectory is null) + { + throw new ArgumentException("Bare repositories not supported.", nameof(workingTreeDirectory)); + } + + this.Commit = committish is null ? this.Repository.Head.Tip : this.Repository.Lookup(committish); + if (this.Commit is null && committish is object) + { + throw new ArgumentException("No matching commit found.", nameof(committish)); + } + + this.VersionFile = new LibGit2VersionFile(this); + } + + /// + public override VersionFile VersionFile { get; } + + /// + public Repository Repository { get; } + + /// + public Commit? Commit { get; private set; } + + /// + public override string? GitCommitId => this.Commit?.Sha; + + /// + public override bool IsHead => this.Repository.Head?.Tip?.Equals(this.Commit) ?? false; + + /// + public override DateTimeOffset? GitCommitDate => this.Commit?.Author.When; + + /// + public override string HeadCanonicalName => this.Repository.Head.CanonicalName; + + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (libgit2)"; + + /// + public override void ApplyTag(string name) => this.Repository.Tags.Add(name, this.Commit); + + /// + public override bool TrySelectCommit(string committish) + { + try + { + this.Repository.RevParse(committish, out Reference? reference, out GitObject obj); + if (obj is Commit commit) + { + this.Commit = commit; + return true; + } + } + catch (NotFoundException) + { + } + + return false; + } + + /// + public override void Stage(string path) => Commands.Stage(this.Repository, path); + + /// + public override string GetShortUniqueCommitId(int minLength) => this.Repository.ObjectDatabase.ShortenObjectId(this.Commit, minLength); + + internal override int CalculateVersionHeight(VersionOptions? committedVersion, VersionOptions? workingVersion) + { + var headCommitVersion = committedVersion?.Version ?? SemVer0; + + if (IsVersionFileChangedInWorkingTree(committedVersion, workingVersion)) + { + var workingCopyVersion = workingVersion?.Version?.Version; + + if (workingCopyVersion is null || !workingCopyVersion.Equals(headCommitVersion)) + { + // The working copy has changed the major.minor version. + // So by definition the version height is 0, since no commit represents it yet. + return 0; + } + } + + return LibGit2GitExtensions.GetVersionHeight(this); + } + + internal override System.Version GetIdAsVersion(VersionOptions? committedVersion, VersionOptions? workingVersion, int versionHeight) + { + VersionOptions? version = IsVersionFileChangedInWorkingTree(committedVersion, workingVersion) ? workingVersion : committedVersion; + + return this.Commit.GetIdAsVersionHelper(version, versionHeight); + } + + /// The path to the .git directory or somewhere in a git working tree. + /// The SHA-1 or ref for a git commit. + public static LibGit2Context Create(string path, string? committish = null) + { + FindGitPaths(path, out string? gitDirectory, out string? workingTreeDirectory, out string? workingTreeRelativePath); + return new LibGit2Context(workingTreeDirectory, gitDirectory, committish) + { + RepoRelativeProjectDirectory = workingTreeRelativePath, + }; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + this.Repository.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Opens a found at or above a specified path. + /// + /// The path to the .git directory or the working directory. + /// + /// Specifies whether to use default settings for looking up global and system settings. + /// + /// By default ( == false), the repository will be configured to only + /// use the repository-level configuration ignoring system or user-level configuration (set using git config --global. + /// Thus only settings explicitly set for the repo will be available. + /// + /// + /// For example using Repository.Configuration.Get{string}("user.name") to get the user's name will + /// return the value set in the repository config or null if the user name has not been explicitly set for the repository. + /// + /// + /// When the caller specifies to use the default configuration search paths ( == true) + /// both repository level and global configuration will be available to the repo as well. + /// + /// + /// In this mode, using Repository.Configuration.Get{string}("user.name") will return the + /// value set in the user's global git configuration unless set on the repository level, + /// matching the behavior of the git command. + /// + /// + /// The found for the specified path, or null if no git repo is found. + internal static Repository OpenGitRepo(string path, bool useDefaultConfigSearchPaths = false) + { + if (useDefaultConfigSearchPaths) + { + // pass null to reset to defaults + GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.Global, null); + GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.System, null); + } + else + { + // Override Config Search paths to empty path to avoid new Repository instance to lookup for Global\System .gitconfig file + GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.Global, string.Empty); + GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.System, string.Empty); + } + + return new Repository(path); + } + } +} diff --git a/src/NerdBank.GitVersioning/GitExtensions.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2GitExtensions.cs similarity index 52% rename from src/NerdBank.GitVersioning/GitExtensions.cs rename to src/NerdBank.GitVersioning/LibGit2/LibGit2GitExtensions.cs index e9768a2c..3d17c5f6 100644 --- a/src/NerdBank.GitVersioning/GitExtensions.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2GitExtensions.cs @@ -1,4 +1,6 @@ -namespace Nerdbank.GitVersioning +#nullable enable + +namespace Nerdbank.GitVersioning.LibGit2 { using System; using System.Collections.Generic; @@ -14,7 +16,7 @@ /// /// Git extension methods. /// - public static class GitExtensions + public static class LibGit2GitExtensions { /// /// The 0.0 version. @@ -43,20 +45,21 @@ public static class GitExtensions /// /// Gets the number of commits in the longest single path between /// the specified commit and the most distant ancestor (inclusive) - /// that set the version to the value at . + /// that set the version to the value at . /// - /// The commit to measure the height of. - /// The repo-relative project directory for which to calculate the version. + /// The git context to read from. /// Optional base version to calculate the height. If not specified, the base version will be calculated by scanning the repository. /// The height of the commit. Always a positive integer. - internal static int GetVersionHeight(this Commit commit, string repoRelativeProjectDirectory = null, Version baseVersion = null) + internal static int GetVersionHeight(LibGit2Context context, Version? baseVersion = null) { - Requires.NotNull(commit, nameof(commit)); - Requires.Argument(repoRelativeProjectDirectory == null || !Path.IsPathRooted(repoRelativeProjectDirectory), nameof(repoRelativeProjectDirectory), "Path should be relative to repo root."); + if (context.Commit is null) + { + return 0; + } - var tracker = new GitWalkTracker(repoRelativeProjectDirectory); + var tracker = new GitWalkTracker(context); - var versionOptions = tracker.GetVersion(commit); + var versionOptions = tracker.GetVersion(context.Commit); if (versionOptions == null) { return 0; @@ -69,7 +72,7 @@ internal static int GetVersionHeight(this Commit commit, string repoRelativeProj var versionHeightPosition = versionOptions.VersionHeightPosition; if (versionHeightPosition.HasValue) { - int height = commit.GetHeight(repoRelativeProjectDirectory, c => CommitMatchesVersion(c, baseSemVer, versionHeightPosition.Value, tracker)); + int height = GetHeight(context, c => CommitMatchesVersion(c, baseSemVer, versionHeightPosition.Value, tracker)); return height; } @@ -80,69 +83,18 @@ internal static int GetVersionHeight(this Commit commit, string repoRelativeProj /// Gets the number of commits in the longest single path between /// the specified commit and the most distant ancestor (inclusive). /// - /// The commit to measure the height of. + /// The git context to read from. /// /// A function that returns false when we reach a commit that /// should not be included in the height calculation. /// May be null to count the height to the original commit. /// /// The height of the commit. Always a positive integer. - public static int GetHeight(this Commit commit, Func continueStepping = null) + public static int GetHeight(LibGit2Context context, Func? continueStepping = null) { - return commit.GetHeight(null, continueStepping); - } - - /// - /// Gets the number of commits in the longest single path between - /// the specified commit and the most distant ancestor (inclusive). - /// - /// The commit to measure the height of. - /// The path to the directory of the project whose version is being queried, relative to the repo root. - /// - /// A function that returns false when we reach a commit that - /// should not be included in the height calculation. - /// May be null to count the height to the original commit. - /// - /// The height of the commit. Always a positive integer. - public static int GetHeight(this Commit commit, string repoRelativeProjectDirectory, Func continueStepping = null) - { - Requires.NotNull(commit, nameof(commit)); - - var tracker = new GitWalkTracker(repoRelativeProjectDirectory); - return GetCommitHeight(commit, tracker, continueStepping); - } - - /// - /// Gets the number of commits in the longest single path between - /// the specified branch's head and the most distant ancestor (inclusive). - /// - /// The branch to measure the height of. - /// - /// A function that returns false when we reach a commit that - /// should not be included in the height calculation. - /// May be null to count the height to the original commit. - /// - /// The height of the branch. - public static int GetHeight(this Branch branch, Func continueStepping = null) - { - return branch.GetHeight(null, continueStepping); - } - - /// - /// Gets the number of commits in the longest single path between - /// the specified branch's head and the most distant ancestor (inclusive). - /// - /// The branch to measure the height of. - /// The path to the directory of the project whose version is being queried, relative to the repo root. - /// - /// A function that returns false when we reach a commit that - /// should not be included in the height calculation. - /// May be null to count the height to the original commit. - /// - /// The height of the branch. - public static int GetHeight(this Branch branch, string repoRelativeProjectDirectory, Func continueStepping = null) - { - return GetHeight(branch.Tip ?? throw new InvalidOperationException("No commit exists."), repoRelativeProjectDirectory, continueStepping); + Verify.Operation(context.Commit is object, "No commit is selected."); + var tracker = new GitWalkTracker(context); + return GetCommitHeight(context.Commit, tracker, continueStepping); } /// @@ -193,72 +145,34 @@ private static IRepository GetRepository(this IBelongToARepository repositoryMem return repositoryMember.Repository; } - /// - /// Encodes a commit from history in a - /// so that the original commit can be found later. - /// - /// The commit whose ID and position in history is to be encoded. - /// The repo-relative project directory for which to calculate the version. - /// - /// The version height, previously calculated by a call to - /// with the same value for . - /// - /// - /// A version whose and - /// components are calculated based on the commit. - /// - /// - /// In the returned version, the component is - /// the height of the git commit while the - /// component is the first four bytes of the git commit id (forced to be a positive integer). - /// - internal static Version GetIdAsVersion(this Commit commit, string repoRelativeProjectDirectory = null, int? versionHeight = null) - { - Requires.NotNull(commit, nameof(commit)); - Requires.Argument(repoRelativeProjectDirectory == null || !Path.IsPathRooted(repoRelativeProjectDirectory), nameof(repoRelativeProjectDirectory), "Path should be relative to repo root."); - - var versionOptions = VersionFile.GetVersion(commit, repoRelativeProjectDirectory); - - if (!versionHeight.HasValue) - { - versionHeight = GetVersionHeight(commit, repoRelativeProjectDirectory); - } - - return GetIdAsVersionHelper(commit, versionOptions, versionHeight.Value); - } - /// /// Looks up the commit that matches a specified version number. /// - /// The repository to search for a matching commit. - /// The version previously obtained from . - /// - /// The repo-relative project directory from which was originally calculated. - /// + /// The git context to read from. + /// The version previously obtained from . /// The matching commit, or null if no match is found. /// /// Thrown in the very rare situation that more than one matching commit is found. /// - public static Commit GetCommitFromVersion(this Repository repo, Version version, string repoRelativeProjectDirectory = null) + public static Commit GetCommitFromVersion(LibGit2Context context, Version version) { // Note we'll accept no match, or one match. But we throw if there is more than one match. - return GetCommitsFromVersion(repo, version, repoRelativeProjectDirectory).SingleOrDefault(); + return GetCommitsFromVersion(context, version).SingleOrDefault(); } /// /// Looks up the commits that match a specified version number. /// - /// The repository to search for a matching commit. - /// The version previously obtained from . - /// The repo-relative project directory from which was originally calculated. + /// The git context to read from. + /// The version previously obtained from . /// The matching commits, or an empty enumeration if no match is found. - public static IEnumerable GetCommitsFromVersion(this Repository repo, Version version, string repoRelativeProjectDirectory = null) + public static IEnumerable GetCommitsFromVersion(LibGit2Context context, Version version) { - Requires.NotNull(repo, nameof(repo)); + Requires.NotNull(context, nameof(context)); Requires.NotNull(version, nameof(version)); - var tracker = new GitWalkTracker(repoRelativeProjectDirectory); - var possibleCommits = from commit in GetCommitsReachableFromRefs(repo) + var tracker = new GitWalkTracker(context); + var possibleCommits = from commit in GetCommitsReachableFromRefs(context.Repository) let commitVersionOptions = tracker.GetVersion(commit) where commitVersionOptions != null where !IsCommitIdMismatch(version, commitVersionOptions, commit) @@ -268,81 +182,12 @@ public static IEnumerable GetCommitsFromVersion(this Repository repo, Ve return possibleCommits; } - /// - /// Assists the operating system in finding the appropriate native libgit2 module. - /// - /// The path to the directory that contains the lib folder. - /// Thrown if the provided path does not lead to an existing directory. - public static void HelpFindLibGit2NativeBinaries(string basePath) - { - HelpFindLibGit2NativeBinaries(basePath, out string _); - } - - /// - /// Assists the operating system in finding the appropriate native libgit2 module. - /// - /// The path to the directory that contains the lib folder. - /// Receives the directory that native binaries are expected. - /// Thrown if the provided path does not lead to an existing directory. - public static void HelpFindLibGit2NativeBinaries(string basePath, out string attemptedDirectory) - { - if (!TryHelpFindLibGit2NativeBinaries(basePath, out attemptedDirectory)) - { - throw new ArgumentException($"Unable to find native binaries under directory: \"{attemptedDirectory}\"."); - } - } - - /// - /// Assists the operating system in finding the appropriate native libgit2 module. - /// - /// The path to the directory that contains the lib folder. - /// true if the libgit2 native binaries have been found; false otherwise. - public static bool TryHelpFindLibGit2NativeBinaries(string basePath) - { - return TryHelpFindLibGit2NativeBinaries(basePath, out string _); - } - - /// - /// Assists the operating system in finding the appropriate native libgit2 module. - /// - /// The path to the directory that contains the lib folder. - /// Receives the directory that native binaries are expected. - /// true if the libgit2 native binaries have been found; false otherwise. - public static bool TryHelpFindLibGit2NativeBinaries(string basePath, out string attemptedDirectory) - { - attemptedDirectory = FindLibGit2NativeBinaries(basePath); - if (Directory.Exists(attemptedDirectory)) - { - AddDirectoryToPath(attemptedDirectory); - return true; - } - - return false; - } - - /// - /// Add a directory to the PATH environment variable if it isn't already present. - /// - /// The directory to be added. - public static void AddDirectoryToPath(string directory) - { - Requires.NotNullOrEmpty(directory, nameof(directory)); - - string pathEnvVar = Environment.GetEnvironmentVariable("PATH"); - string[] searchPaths = pathEnvVar.Split(Path.PathSeparator); - if (!searchPaths.Contains(directory, StringComparer.OrdinalIgnoreCase)) - { - pathEnvVar += Path.PathSeparator + directory; - Environment.SetEnvironmentVariable("PATH", pathEnvVar); - } - } - /// /// Finds the directory that contains the appropriate native libgit2 module. /// /// The path to the directory that contains the lib folder. /// Receives the directory that native binaries are expected. - public static string FindLibGit2NativeBinaries(string basePath) + public static string? FindLibGit2NativeBinaries(string basePath) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -360,118 +205,6 @@ public static string FindLibGit2NativeBinaries(string basePath) return null; } - /// - /// Opens a found at or above a specified path. - /// - /// The path at or beneath the git repo root. - /// - /// Specifies whether to use default settings for looking up global and system settings. - /// - /// By default ( == false), the repository will be configured to only - /// use the repository-level configuration ignoring system or user-level configuration (set using git config --global. - /// Thus only settings explicitly set for the repo will be available. - /// - /// - /// For example using Repository.Configuration.Get{string}("user.name") to get the user's name will - /// return the value set in the repository config or null if the user name has not been explicitly set for the repository. - /// - /// - /// When the caller specifies to use the default configuration search paths ( == true) - /// both repository level and global configuration will be available to the repo as well. - /// - /// - /// In this mode, using Repository.Configuration.Get{string}("user.name") will return the - /// value set in the user's global git configuration unless set on the repository level, - /// matching the behavior of the git command. - /// - /// - /// The found for the specified path, or null if no git repo is found. - public static Repository OpenGitRepo(string pathUnderGitRepo, bool useDefaultConfigSearchPaths = false) - { - Requires.NotNullOrEmpty(pathUnderGitRepo, nameof(pathUnderGitRepo)); - var gitDir = FindGitDir(pathUnderGitRepo); - - if (useDefaultConfigSearchPaths) - { - // pass null to reset to defaults - GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.Global, null); - GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.System, null); - } - else - { - // Override Config Search paths to empty path to avoid new Repository instance to lookup for Global\System .gitconfig file - GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.Global, string.Empty); - GlobalSettings.SetConfigSearchPaths(ConfigurationLevel.System, string.Empty); - } - - return gitDir == null ? null : new Repository(gitDir); - } - - /// - /// Tests whether two instances are compatible enough that version height is not reset - /// when progressing from one to the next. - /// - /// One set of version options. - /// Another set of version options. - /// true if transitioning from one version to the next should reset the version height; false otherwise. - internal static bool WillVersionChangeResetVersionHeight(VersionOptions first, VersionOptions second) - { - Requires.NotNull(first, nameof(first)); - Requires.NotNull(second, nameof(second)); - - // If the version height position moved, that's an automatic reset in version height. - if (first.VersionHeightPosition != second.VersionHeightPosition) - { - return true; - } - - if (!first.VersionHeightPosition.HasValue) - { - // There's no version height anywhere, so go ahead and say it would be reset. - // This is useful to our `nbgv prepare-release` command to know that it can remove the version height adjustment property. - return true; - } - - return WillVersionChangeResetVersionHeight(first.Version, second.Version, first.VersionHeightPosition.Value); - } - - /// - /// Tests whether two instances are compatible enough that version height is not reset - /// when progressing from one to the next. - /// - /// The first semantic version. - /// The second semantic version. - /// The position within the version where height is tracked. - /// true if transitioning from one version to the next should reset the version height; false otherwise. - internal static bool WillVersionChangeResetVersionHeight(SemanticVersion first, SemanticVersion second, SemanticVersion.Position versionHeightPosition) - { - Requires.NotNull(first, nameof(first)); - Requires.NotNull(second, nameof(second)); - - if (first == second) - { - return false; - } - - if (versionHeightPosition == SemanticVersion.Position.Prerelease) - { - // The entire version spec must match exactly. - return !first.Equals(second); - } - - for (SemanticVersion.Position position = SemanticVersion.Position.Major; position <= versionHeightPosition; position++) - { - int expectedValue = ReadVersionPosition(second.Version, position); - int actualValue = ReadVersionPosition(first.Version, position); - if (expectedValue != actualValue) - { - return true; - } - } - - return false; - } - /// /// Tests whether a commit is of a specified version, comparing major and minor components /// with the version.txt file defined by that commit. @@ -494,12 +227,12 @@ private static bool CommitMatchesVersion(this Commit commit, SemanticVersion exp } // If the version height position moved, that's an automatic reset in version height. - if (commitVersionData.VersionHeightPosition != comparisonPrecision) + if (commitVersionData!.VersionHeightPosition != comparisonPrecision) { return false; } - return !WillVersionChangeResetVersionHeight(commitVersionData.Version, expectedVersion, comparisonPrecision); + return !SemanticVersion.WillVersionChangeResetVersionHeight(commitVersionData.Version, expectedVersion, comparisonPrecision); } /// @@ -524,8 +257,8 @@ private static bool CommitMatchesVersion(this Commit commit, Version expectedVer for (SemanticVersion.Position position = SemanticVersion.Position.Major; position <= comparisonPrecision; position++) { - int expectedValue = ReadVersionPosition(expectedVersion, position); - int actualValue = ReadVersionPosition(semVerFromFile.Version, position); + int expectedValue = SemanticVersion.ReadVersionPosition(expectedVersion, position); + int actualValue = SemanticVersion.ReadVersionPosition(semVerFromFile.Version, position); if (expectedValue != actualValue) { return false; @@ -535,25 +268,6 @@ private static bool CommitMatchesVersion(this Commit commit, Version expectedVer return true; } - private static int ReadVersionPosition(Version version, SemanticVersion.Position position) - { - Requires.NotNull(version, nameof(version)); - - switch (position) - { - case SemanticVersion.Position.Major: - return version.Major; - case SemanticVersion.Position.Minor: - return version.Minor; - case SemanticVersion.Position.Build: - return version.Build; - case SemanticVersion.Position.Revision: - return version.Revision; - default: - throw new ArgumentOutOfRangeException(nameof(position), position, "Must be one of the 4 integer parts."); - } - } - private static bool IsVersionHeightMismatch(Version version, VersionOptions versionOptions, Commit commit, GitWalkTracker tracker) { Requires.NotNull(version, nameof(version)); @@ -564,7 +278,7 @@ private static bool IsVersionHeightMismatch(Version version, VersionOptions vers var position = versionOptions.VersionHeightPosition; if (position.HasValue && position.Value <= SemanticVersion.Position.Revision) { - int expectedVersionHeight = ReadVersionPosition(version, position.Value); + int expectedVersionHeight = SemanticVersion.ReadVersionPosition(version, position.Value); var actualVersionOffset = versionOptions.VersionHeightOffsetOrDefault; var actualVersionHeight = GetCommitHeight(commit, tracker, c => CommitMatchesVersion(c, version, position.Value - 1, tracker)); @@ -590,7 +304,7 @@ private static bool IsCommitIdMismatch(Version version, VersionOptions versionOp // The revision is a 16-bit unsigned integer, but is not allowed to be 0xffff. // So if the value is 0xfffe, consider that the actual last bit is insignificant // since the original git commit ID could have been either 0xffff or 0xfffe. - var expectedCommitIdLeadingValue = ReadVersionPosition(version, position.Value); + var expectedCommitIdLeadingValue = SemanticVersion.ReadVersionPosition(version, position.Value); if (expectedCommitIdLeadingValue != -1) { ushort objectIdLeadingValue = (ushort)expectedCommitIdLeadingValue; @@ -604,46 +318,6 @@ private static bool IsCommitIdMismatch(Version version, VersionOptions versionOp return false; } - private static string FindGitDir(string startingDir) - { - while (startingDir != null) - { - var dirOrFilePath = Path.Combine(startingDir, ".git"); - if (Directory.Exists(dirOrFilePath)) - { - return dirOrFilePath; - } - else if (File.Exists(dirOrFilePath)) - { - var relativeGitDirPath = ReadGitDirFromFile(dirOrFilePath); - if (!string.IsNullOrWhiteSpace(relativeGitDirPath)) - { - var fullGitDirPath = Path.GetFullPath(Path.Combine(startingDir, relativeGitDirPath)); - if (Directory.Exists(fullGitDirPath)) - { - return fullGitDirPath; - } - } - } - - startingDir = Path.GetDirectoryName(startingDir); - } - - return null; - } - - private static string ReadGitDirFromFile(string fileName) - { - const string expectedPrefix = "gitdir: "; - var firstLineOfFile = File.ReadLines(fileName).FirstOrDefault(); - if (firstLineOfFile?.StartsWith(expectedPrefix) ?? false) - { - return firstLineOfFile.Substring(expectedPrefix.Length); // strip off the prefix, leaving just the path - } - - return null; - } - /// /// Tests whether an object's ID starts with the specified 16-bits, or a subset of them. /// @@ -687,7 +361,7 @@ private static string EncodeAsHex(byte[] buffer) /// May be null to count the height to the original commit. /// /// The height of the branch. - private static int GetCommitHeight(Commit startingCommit, GitWalkTracker tracker, Func continueStepping) + private static int GetCommitHeight(Commit startingCommit, GitWalkTracker tracker, Func? continueStepping) { Requires.NotNull(startingCommit, nameof(startingCommit)); Requires.NotNull(tracker, nameof(tracker)); @@ -740,7 +414,7 @@ bool TryCalculateHeight(Commit commit) var ignoreCase = commit.GetRepository().Config.Get("core.ignorecase")?.Value ?? false; bool ContainsRelevantChanges(IEnumerable changes) => - excludePaths.Count == 0 + excludePaths?.Count == 0 ? changes.Any() // If there is a single change that isn't excluded, // then this commit is relevant. @@ -835,7 +509,7 @@ private static IEnumerable GetCommitsReachableFromRefs(Repository repo) /// /// The commit whose ID and position in history is to be encoded. /// The version options applicable at this point (either from commit or working copy). - /// The version height, previously calculated by a call to . + /// The version height, previously calculated by a call to . /// /// A version whose and /// components are calculated based on the commit. @@ -845,7 +519,7 @@ private static IEnumerable GetCommitsReachableFromRefs(Repository repo) /// the height of the git commit while the /// component is the first four bytes of the git commit id (forced to be a positive integer). /// - internal static Version GetIdAsVersionHelper(this Commit commit, VersionOptions versionOptions, int versionHeight) + internal static Version GetIdAsVersionHelper(this Commit? commit, VersionOptions? versionOptions, int versionHeight) { var baseVersion = versionOptions?.Version?.Version ?? Version0; int buildNumber = baseVersion.Build; @@ -878,7 +552,7 @@ internal static Version GetIdAsVersionHelper(this Commit commit, VersionOptions switch (commitIdPosition.Value) { case SemanticVersion.Position.Revision: - revision = commit != null + revision = commit is object ? Math.Min(MaximumBuildNumberOrRevisionComponent, commit.GetTruncatedCommitIdAsUInt16()) : 0; break; @@ -888,78 +562,29 @@ internal static Version GetIdAsVersionHelper(this Commit commit, VersionOptions return VersionExtensions.Create(baseVersion.Major, baseVersion.Minor, buildNumber, revision); } - /// - /// Gets the version options from HEAD and the working copy (if applicable), - /// and tests their equality. - /// - /// The repo to scan for version info. - /// The path to the directory of the project whose version is being queried, relative to the repo root. - /// Receives the version options from the HEAD commit. - /// Receives the version options from the working copy, when applicable. - /// true if and are not equal. - private static bool IsVersionFileChangedInWorkingCopy(Repository repo, string repoRelativeProjectDirectory, out VersionOptions committedVersion, out VersionOptions workingCopyVersion) - { - Requires.NotNull(repo, nameof(repo)); - Commit headCommit = repo.Head.Tip; - committedVersion = VersionFile.GetVersion(headCommit, repoRelativeProjectDirectory); - - if (!repo.Info.IsBare) - { - string fullDirectory = Path.Combine(repo.Info.WorkingDirectory, repoRelativeProjectDirectory ?? string.Empty); - workingCopyVersion = VersionFile.GetVersion(fullDirectory); - return !EqualityComparer.Default.Equals(workingCopyVersion, committedVersion); - } - - workingCopyVersion = null; - return false; - } - - internal static string GetRepoRelativePath(this Repository repo, string absolutePath) - { - var repoRoot = repo?.Info?.WorkingDirectory?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && repoRoot != null && repoRoot.StartsWith("\\") && (repoRoot.Length == 1 || repoRoot[1] != '\\')) - { - // We're in a worktree, which libgit2sharp only gives us as a path relative to the root of the assumed drive. - // Add the drive: to the front of the repoRoot. - repoRoot = repo.Info.Path.Substring(0, 2) + repoRoot; - } - - if (repoRoot == null) - return null; - - if (!absolutePath.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException($"Path '{absolutePath}' is not within repository '{repoRoot}'", nameof(absolutePath)); - } - - return absolutePath.Substring(repoRoot.Length) - .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - private class GitWalkTracker { - private readonly Dictionary commitVersionCache = new Dictionary(); - private readonly Dictionary blobVersionCache = new Dictionary(); + private readonly Dictionary commitVersionCache = new Dictionary(); + private readonly Dictionary blobVersionCache = new Dictionary(); private readonly Dictionary heights = new Dictionary(); + private readonly LibGit2Context context; - internal GitWalkTracker(string repoRelativeDirectory) + internal GitWalkTracker(LibGit2Context context) { - this.RepoRelativeDirectory = repoRelativeDirectory; + this.context = context; } - internal string RepoRelativeDirectory { get; } - internal bool TryGetVersionHeight(Commit commit, out int height) => this.heights.TryGetValue(commit.Id, out height); internal void RecordHeight(Commit commit, int height) => this.heights.Add(commit.Id, height); - internal VersionOptions GetVersion(Commit commit) + internal VersionOptions? GetVersion(Commit commit) { - if (!this.commitVersionCache.TryGetValue(commit.Id, out VersionOptions options)) + if (!this.commitVersionCache.TryGetValue(commit.Id, out VersionOptions? options)) { try { - options = VersionFile.GetVersion(commit, this.RepoRelativeDirectory, this.blobVersionCache); + options = ((LibGit2VersionFile)this.context.VersionFile).GetVersion(commit, this.context.RepoRelativeProjectDirectory, this.blobVersionCache, out string? actualDirectory); } catch (Exception ex) { diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs new file mode 100644 index 00000000..f6829530 --- /dev/null +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2VersionFile.cs @@ -0,0 +1,140 @@ +#nullable enable + +namespace Nerdbank.GitVersioning.LibGit2 +{ + using System; + using System.Collections.Generic; + using System.IO; + using LibGit2Sharp; + using Newtonsoft.Json; + + /// + /// Exposes queries and mutations on a version.json or version.txt file, + /// implemented in terms of libgit2sharp. + /// + internal class LibGit2VersionFile : VersionFile + { + /// + /// A sequence of possible filenames for the version file in preferred order. + /// + public static readonly IReadOnlyList PreferredFileNames = new[] { JsonFileName, TxtFileName }; + + internal LibGit2VersionFile(LibGit2Context context) + : base(context) + { + } + + protected new LibGit2Context Context => (LibGit2Context)base.Context; + + protected override VersionOptions? GetVersionCore(out string? actualDirectory) => this.GetVersion(this.Context.Commit!, this.Context.RepoRelativeProjectDirectory, null, out actualDirectory); + + /// + /// Reads the version.json file and returns the deserialized from it. + /// + /// The commit to read from. + /// The directory to consider when searching for the version.txt file. + /// An optional blob cache for storing the raw parse results of a version.txt or version.json file (before any inherit merge operations are applied). + /// Receives the full path to the directory in which the version file was found. + /// The version information read from the file. + internal VersionOptions? GetVersion(Commit commit, string repoRelativeProjectDirectory, Dictionary? blobVersionCache, out string? actualDirectory) + { + string? searchDirectory = repoRelativeProjectDirectory ?? string.Empty; + while (searchDirectory is object) + { + string? parentDirectory = searchDirectory.Length > 0 ? Path.GetDirectoryName(searchDirectory) : null; + + string candidatePath = Path.Combine(searchDirectory, TxtFileName).Replace('\\', '/'); + var versionTxtBlob = commit.Tree[candidatePath]?.Target as Blob; + if (versionTxtBlob is object) + { + if (blobVersionCache is null || !blobVersionCache.TryGetValue(versionTxtBlob.Id, out VersionOptions? result)) + { + result = TryReadVersionFile(new StreamReader(versionTxtBlob.GetContentStream())); + if (blobVersionCache is object) + { + result?.Freeze(); + blobVersionCache.Add(versionTxtBlob.Id, result); + } + } + + if (result is object) + { + actualDirectory = searchDirectory; + return result; + } + } + + candidatePath = Path.Combine(searchDirectory, JsonFileName).Replace('\\', '/'); + var versionJsonBlob = commit.Tree[candidatePath]?.Target as LibGit2Sharp.Blob; + if (versionJsonBlob is object) + { + string? versionJsonContent = null; + if (blobVersionCache is null || !blobVersionCache.TryGetValue(versionJsonBlob.Id, out VersionOptions? result)) + { + using (var sr = new StreamReader(versionJsonBlob.GetContentStream())) + { + versionJsonContent = sr.ReadToEnd(); + } + + try + { + result = TryReadVersionJsonContent(versionJsonContent, searchDirectory); + } + catch (FormatException ex) + { + throw new FormatException( + $"Failure while reading {JsonFileName} from commit {this.Context.GitCommitId}. " + + "Fix this commit with rebase if this is an error, or review this doc on how to migrate to Nerdbank.GitVersioning: " + + "https://github.com/dotnet/Nerdbank.GitVersioning/blob/master/doc/migrating.md", ex); + } + + if (blobVersionCache is object) + { + result?.Freeze(); + blobVersionCache.Add(versionJsonBlob.Id, result); + } + } + + if (result?.Inherit ?? false) + { + if (parentDirectory is object) + { + result = this.GetVersion(commit, parentDirectory, blobVersionCache, out actualDirectory); + if (result is object) + { + if (versionJsonContent is null) + { + // We reused a cache VersionOptions, but now we need the actual JSON string. + using (var sr = new StreamReader(versionJsonBlob.GetContentStream())) + { + versionJsonContent = sr.ReadToEnd(); + } + } + + if (result.IsFrozen) + { + result = new VersionOptions(result); + } + + JsonConvert.PopulateObject(versionJsonContent, result, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: searchDirectory)); + return result; + } + } + + throw new InvalidOperationException($"\"{candidatePath}\" inherits from a parent directory version.json file but none exists."); + } + else if (result is object) + { + actualDirectory = searchDirectory; + return result; + } + } + + searchDirectory = parentDirectory; + } + + actualDirectory = null; + return null; + } + } +} diff --git a/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs b/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs new file mode 100644 index 00000000..3894e46f --- /dev/null +++ b/src/NerdBank.GitVersioning/Managed/ManagedGitContext.cs @@ -0,0 +1,180 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Nerdbank.GitVersioning.ManagedGit; +using Validation; + +namespace Nerdbank.GitVersioning.Managed +{ + /// + /// A git context implemented without any native code dependency. + /// + [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + public class ManagedGitContext : GitContext + { + internal ManagedGitContext(string workingDirectory, string dotGitPath, string? committish = null) + : base(workingDirectory, dotGitPath) + { + GitRepository? repo = GitRepository.Create(workingDirectory); + if (repo is null) + { + throw new ArgumentException("No git repo found here.", nameof(workingDirectory)); + } + + this.Commit = committish is null ? repo.GetHeadCommit() : (repo.Lookup(committish) is { } objectId ? (GitCommit?)repo.GetCommit(objectId) : null); + if (this.Commit is null && committish is object) + { + throw new ArgumentException("No matching commit found.", nameof(committish)); + } + + this.Repository = repo; + this.VersionFile = new ManagedVersionFile(this); + } + + /// + public GitRepository Repository { get; } + + /// + public GitCommit? Commit { get; private set; } + + /// + public override VersionFile VersionFile { get; } + + /// + public override string? GitCommitId => this.Commit?.Sha.ToString(); + + /// + public override bool IsHead => this.Repository.GetHeadCommit().Equals(this.Commit); + + /// + public override DateTimeOffset? GitCommitDate => this.Commit is { } commit ? (commit.Author?.Date ?? this.Repository.GetCommit(commit.Sha, readAuthor: true).Author?.Date) : null; + + /// + public override string HeadCanonicalName => this.Repository.GetHeadAsReferenceOrSha().ToString() ?? throw new InvalidOperationException("Unable to determine the HEAD position."); + + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (managed)"; + + /// + public override void ApplyTag(string name) => throw new NotSupportedException(); + + /// + public override bool TrySelectCommit(string committish) + { + if (this.Repository.Lookup(committish) is { } objectId) + { + this.Commit = this.Repository.GetCommit(objectId); + return true; + } + + return false; + } + + /// + public override void Stage(string path) => throw new NotSupportedException(); + + /// The path to the .git directory or somewhere in a git working tree. + /// The SHA-1 or ref for a git commit. + public static ManagedGitContext Create(string path, string? committish = null) + { + FindGitPaths(path, out string? gitDirectory, out string? workingTreeDirectory, out string? workingTreeRelativePath); + return new ManagedGitContext(workingTreeDirectory, gitDirectory, committish) + { + RepoRelativeProjectDirectory = workingTreeRelativePath, + }; + } + + internal override int CalculateVersionHeight(VersionOptions? committedVersion, VersionOptions? workingVersion) + { + var headCommitVersion = committedVersion?.Version ?? SemVer0; + + if (IsVersionFileChangedInWorkingTree(committedVersion, workingVersion)) + { + var workingCopyVersion = workingVersion?.Version?.Version; + + if (workingCopyVersion == null || !workingCopyVersion.Equals(headCommitVersion)) + { + // The working copy has changed the major.minor version. + // So by definition the version height is 0, since no commit represents it yet. + return 0; + } + } + + return GitExtensions.GetVersionHeight(this); + } + + internal override Version GetIdAsVersion(VersionOptions? committedVersion, VersionOptions? workingVersion, int versionHeight) + { + var version = IsVersionFileChangedInWorkingTree(committedVersion, workingVersion) ? workingVersion : committedVersion; + + return this.GetIdAsVersionHelper(version, versionHeight); + } + + /// + public override string GetShortUniqueCommitId(int minLength) + { + Verify.Operation(this.Commit is object, "No commit is selected."); + return this.Repository.ShortenObjectId(this.Commit.Value.Sha, minLength); + } + + /// + /// Encodes a commit from history in a + /// so that the original commit can be found later. + /// + /// The version options applicable at this point (either from commit or working copy). + /// The version height, previously calculated. + /// + /// A version whose and + /// components are calculated based on the commit. + /// + /// + /// In the returned version, the component is + /// the height of the git commit while the + /// component is the first four bytes of the git commit id (forced to be a positive integer). + /// + private Version GetIdAsVersionHelper(VersionOptions? versionOptions, int versionHeight) + { + var baseVersion = versionOptions?.Version?.Version ?? Version0; + int buildNumber = baseVersion.Build; + int revision = baseVersion.Revision; + + // Don't use the ?? coalescing operator here because the position property getters themselves can return null, which should NOT be overridden with our default. + // The default value is only appropriate if versionOptions itself is null. + var versionHeightPosition = versionOptions != null ? versionOptions.VersionHeightPosition : SemanticVersion.Position.Build; + var commitIdPosition = versionOptions != null ? versionOptions.GitCommitIdPosition : SemanticVersion.Position.Revision; + + // The compiler (due to WinPE header requirements) only allows 16-bit version components, + // and forbids 0xffff as a value. + if (versionHeightPosition.HasValue) + { + int adjustedVersionHeight = versionHeight == 0 ? 0 : versionHeight + (versionOptions?.VersionHeightOffset ?? 0); + Verify.Operation(adjustedVersionHeight <= MaximumBuildNumberOrRevisionComponent, "Git height is {0}, which is greater than the maximum allowed {0}.", adjustedVersionHeight, MaximumBuildNumberOrRevisionComponent); + switch (versionHeightPosition.Value) + { + case SemanticVersion.Position.Build: + buildNumber = adjustedVersionHeight; + break; + case SemanticVersion.Position.Revision: + revision = adjustedVersionHeight; + break; + } + } + + if (commitIdPosition.HasValue) + { + switch (commitIdPosition.Value) + { + case SemanticVersion.Position.Revision: + revision = this.Commit.HasValue + ? Math.Min(MaximumBuildNumberOrRevisionComponent, this.Commit.Value.GetTruncatedCommitIdAsUInt16()) + : 0; + break; + } + } + + return VersionExtensions.Create(baseVersion.Major, baseVersion.Minor, buildNumber, revision); + } + } +} diff --git a/src/NerdBank.GitVersioning/Managed/ManagedGitExtensions.cs b/src/NerdBank.GitVersioning/Managed/ManagedGitExtensions.cs new file mode 100644 index 00000000..a2f1fefc --- /dev/null +++ b/src/NerdBank.GitVersioning/Managed/ManagedGitExtensions.cs @@ -0,0 +1,366 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Nerdbank.GitVersioning.ManagedGit; +using Validation; + +namespace Nerdbank.GitVersioning.Managed +{ + internal static class GitExtensions + { + /// + /// The 0.0 semver. + /// + private static readonly SemanticVersion SemVer0 = SemanticVersion.Parse("0.0"); + + /// + /// Gets the number of commits in the longest single path between + /// the specified commit and the most distant ancestor (inclusive) + /// that set the version to the value at . + /// + /// The git context for which to calculate the height. + /// Optional base version to calculate the height. If not specified, the base version will be calculated by scanning the repository. + /// The height of the commit. Always a positive integer. + internal static int GetVersionHeight(ManagedGitContext context, Version? baseVersion = null) + { + if (context.Commit is null) + { + return 0; + } + + var tracker = new GitWalkTracker(context); + + var versionOptions = tracker.GetVersion(context.Commit.Value); + if (versionOptions == null) + { + return 0; + } + + var baseSemVer = + baseVersion != null ? SemanticVersion.Parse(baseVersion.ToString()) : + versionOptions.Version ?? SemVer0; + + var versionHeightPosition = versionOptions.VersionHeightPosition; + if (versionHeightPosition.HasValue) + { + int height = GetHeight(context, c => CommitMatchesVersion(c, baseSemVer, versionHeightPosition.Value, tracker)); + return height; + } + + return 0; + } + + /// + /// Tests whether a commit is of a specified version, comparing major and minor components + /// with the version.txt file defined by that commit. + /// + /// The commit to test. + /// The version to test for in the commit + /// The last component of the version to include in the comparison. + /// The caching tracker for storing or fetching version information per commit. + /// true if the matches the major and minor components of . + private static bool CommitMatchesVersion(GitCommit commit, SemanticVersion expectedVersion, SemanticVersion.Position comparisonPrecision, GitWalkTracker tracker) + { + Requires.NotNull(expectedVersion, nameof(expectedVersion)); + + var commitVersionData = tracker.GetVersion(commit); + var semVerFromFile = commitVersionData?.Version; + if (commitVersionData == null || semVerFromFile == null) + { + return false; + } + + // If the version height position moved, that's an automatic reset in version height. + if (commitVersionData.VersionHeightPosition != comparisonPrecision) + { + return false; + } + + return !SemanticVersion.WillVersionChangeResetVersionHeight(commitVersionData.Version, expectedVersion, comparisonPrecision); + } + + /// + /// Gets the number of commits in the longest single path between + /// the specified commit and the most distant ancestor (inclusive). + /// + /// The git context. + /// + /// A function that returns false when we reach a commit that + /// should not be included in the height calculation. + /// May be null to count the height to the original commit. + /// + /// The height of the commit. Always a positive integer. + public static int GetHeight(ManagedGitContext context, Func? continueStepping = null) + { + Verify.Operation(context.Commit.HasValue, "No commit is selected."); + var tracker = new GitWalkTracker(context); + return GetCommitHeight(context.Repository, context.Commit.Value, tracker, continueStepping); + } + + /// + /// Gets the number of commits in the longest single path between + /// the specified branch's head and the most distant ancestor (inclusive). + /// + /// The Git repository. + /// The commit to measure the height of. + /// The caching tracker for storing or fetching version information per commit. + /// + /// A function that returns false when we reach a commit that + /// should not be included in the height calculation. + /// May be null to count the height to the original commit. + /// + /// The height of the branch. + private static int GetCommitHeight(GitRepository repository, GitCommit startingCommit, GitWalkTracker tracker, Func? continueStepping) + { + if (continueStepping is object && !continueStepping(startingCommit)) + { + return 0; + } + + var commitsToEvaluate = new Stack(); + bool TryCalculateHeight(GitCommit commit) + { + // Get max height among all parents, or schedule all missing parents for their own evaluation and return false. + int maxHeightAmongParents = 0; + bool parentMissing = false; + foreach (GitObjectId parentId in commit.Parents) + { + var parent = repository.GetCommit(parentId); + if (!tracker.TryGetVersionHeight(parent, out int parentHeight)) + { + if (continueStepping is object && !continueStepping(parent)) + { + // This parent isn't supposed to contribute to height. + continue; + } + + commitsToEvaluate.Push(parent); + parentMissing = true; + } + else + { + maxHeightAmongParents = Math.Max(maxHeightAmongParents, parentHeight); + } + } + + if (parentMissing) + { + return false; + } + + var versionOptions = tracker.GetVersion(commit); + var pathFilters = versionOptions?.PathFilters; + + var includePaths = + pathFilters + ?.Where(filter => !filter.IsExclude) + .Select(filter => filter.RepoRelativePath) + .ToList(); + + var excludePaths = pathFilters?.Where(filter => filter.IsExclude).ToList(); + + var ignoreCase = repository.IgnoreCase; + + /* + bool ContainsRelevantChanges(IEnumerable changes) => + excludePaths.Count == 0 + ? changes.Any() + // If there is a single change that isn't excluded, + // then this commit is relevant. + : changes.Any(change => !excludePaths.Any(exclude => exclude.Excludes(change.Path, ignoreCase))); + */ + + int height = 1; + + if (pathFilters != null) + { + var relevantCommit = true; + + foreach (var parentId in commit.Parents) + { + var parent = repository.GetCommit(parentId); + relevantCommit = IsRelevantCommit(repository, commit, parent, pathFilters); + + // If the diff between this commit and any of its parents + // does not touch a path that we care about, don't bump the + // height. + if (!relevantCommit) + { + break; + } + } + + /* + // If there are no include paths, or any of the include + // paths refer to the root of the repository, then do not + // filter the diff at all. + var diffInclude = + includePaths.Count == 0 || pathFilters.Any(filter => filter.IsRoot) + ? null + : includePaths; + + // If the diff between this commit and any of its parents + // does not touch a path that we care about, don't bump the + // height. + var relevantCommit = + commit.Parents.Any() + ? commit.Parents.Any(parent => ContainsRelevantChanges(commit.GetRepository().Diff + .Compare(parent.Tree, commit.Tree, diffInclude, DiffOptions))) + : ContainsRelevantChanges(commit.GetRepository().Diff + .Compare(null, commit.Tree, diffInclude, DiffOptions)); + */ + + if (!relevantCommit) + { + height = 0; + } + } + + tracker.RecordHeight(commit, height + maxHeightAmongParents); + return true; + } + + commitsToEvaluate.Push(startingCommit); + while (commitsToEvaluate.Count > 0) + { + GitCommit commit = commitsToEvaluate.Peek(); + if (tracker.TryGetVersionHeight(commit, out _) || TryCalculateHeight(commit)) + { + commitsToEvaluate.Pop(); + } + } + + Assumes.True(tracker.TryGetVersionHeight(startingCommit, out int result)); + return result; + } + + private static bool IsRelevantCommit(GitRepository repository, GitCommit commit, GitCommit parent, IReadOnlyList filters) + { + return IsRelevantCommit( + repository, + repository.GetTree(commit.Tree), + repository.GetTree(parent.Tree), + relativePath: string.Empty, + filters); + } + + private static bool IsRelevantCommit(GitRepository repository, GitTree tree, GitTree parent, string relativePath, IReadOnlyList filters) + { + // Walk over all child nodes in the current tree. If a child node was found in the parent, + // remove it, so that after the iteration the parent contains all nodes which have been + // deleted. + foreach (var child in tree.Children) + { + var entry = child.Value; + GitTreeEntry? parentEntry = null; + + // If the entry is not present in the parent commit, it was added; + // if the Sha does not match, it was modified. + if (!parent.Children.TryGetValue(child.Key, out parentEntry) + || parentEntry.Sha != child.Value.Sha) + { + // Determine whether the change was relevant. + var fullPath = $"{relativePath}{entry.Name}"; + + bool isRelevant = + // Either there are no include filters at all (i.e. everything is included), or there's an explicit include filter + (!filters.Any(f => f.IsInclude) || filters.Any(f => f.Includes(fullPath, repository.IgnoreCase))) + // The path is not excluded by any filters + && !filters.Any(f => f.Excludes(fullPath, repository.IgnoreCase)); + + // If the change was relevant, and the item is a directory, we need to recurse. + if (isRelevant && !entry.IsFile) + { + isRelevant = IsRelevantCommit( + repository, + repository.GetTree(entry.Sha), + parentEntry == null ? GitTree.Empty : repository.GetTree(parentEntry.Sha), + $"{fullPath}/", + filters); + } + + // Quit as soon as any relevant change has been detected. + if (isRelevant) + { + return true; + } + } + + if (parentEntry != null) + { + parent.Children.Remove(child.Key); + } + } + + // Inspect removed entries (i.e. present in parent but not in the current tree) + foreach (var child in parent.Children) + { + // Determine whether the change was relevant. + var fullPath = Path.Combine(relativePath, child.Key); + + bool isRelevant = + filters.Any(f => f.Includes(fullPath, repository.IgnoreCase)) + && !filters.Any(f => f.Excludes(fullPath, repository.IgnoreCase)); + + if (isRelevant) + { + return true; + } + } + + // No relevant changes have been detected + return false; + } + + /// + /// Takes the first 2 bytes of a commit ID (i.e. first 4 characters of its hex-encoded SHA) + /// and returns them as an 16-bit unsigned integer. + /// + /// The commit to identify with an integer. + /// The unsigned integer which identifies a commit. + public static ushort GetTruncatedCommitIdAsUInt16(this GitCommit commit) + { + return commit.Sha.AsUInt16(); + } + + private class GitWalkTracker + { + private readonly Dictionary commitVersionCache = new Dictionary(); + private readonly Dictionary blobVersionCache = new Dictionary(); + private readonly Dictionary heights = new Dictionary(); + private readonly ManagedGitContext context; + + internal GitWalkTracker(ManagedGitContext context) + { + this.context = context; + } + + internal bool TryGetVersionHeight(GitCommit commit, out int height) => this.heights.TryGetValue(commit.Sha, out height); + + internal void RecordHeight(GitCommit commit, int height) => this.heights.Add(commit.Sha, height); + + internal VersionOptions? GetVersion(GitCommit commit) + { + if (!this.commitVersionCache.TryGetValue(commit.Sha, out VersionOptions? options)) + { + try + { + options = ((ManagedVersionFile)this.context.VersionFile).GetVersion(commit, this.context.RepoRelativeProjectDirectory, this.blobVersionCache, out string? actualDirectory); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unable to get version from commit: {commit.Sha}", ex); + } + + this.commitVersionCache.Add(commit.Sha, options); + } + + return options; + } + } + } +} diff --git a/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs b/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs new file mode 100644 index 00000000..8db7c331 --- /dev/null +++ b/src/NerdBank.GitVersioning/Managed/ManagedVersionFile.cs @@ -0,0 +1,186 @@ +#nullable enable + +namespace Nerdbank.GitVersioning.Managed +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using Nerdbank.GitVersioning; + using Nerdbank.GitVersioning.ManagedGit; + using Newtonsoft.Json; + using Validation; + + /// + /// Exposes queries and mutations on a version.json or version.txt file, + /// implemented in terms of our private managed git implementation. + /// + internal class ManagedVersionFile : VersionFile + { + /// + /// The filename of the version.txt file, as a byte array. + /// + private static readonly byte[] TxtFileNameBytes = Encoding.ASCII.GetBytes(TxtFileName); + + /// + /// The filename of the version.json file, as a byte array. + /// + private static readonly byte[] JsonFileNameBytes = Encoding.ASCII.GetBytes(JsonFileName); + + /// + /// Initializes a new instance of the class. + /// + /// + public ManagedVersionFile(GitContext context) + : base(context) + { + } + + protected new ManagedGitContext Context => (ManagedGitContext)base.Context; + + protected override VersionOptions? GetVersionCore(out string? actualDirectory) => this.GetVersion(this.Context.Commit!.Value, this.Context.RepoRelativeProjectDirectory, null, out actualDirectory); + + /// + /// Reads the version.json file and returns the deserialized from it. + /// + /// The commit to read from. + /// The directory to consider when searching for the version.txt file. + /// An optional blob cache for storing the raw parse results of a version.txt or version.json file (before any inherit merge operations are applied). + /// Receives the full path to the directory in which the version file was found. + /// The version information read from the file. + internal VersionOptions? GetVersion(GitCommit commit, string repoRelativeProjectDirectory, Dictionary? blobVersionCache, out string? actualDirectory) + { + Stack directories = new Stack(); + + string? currentDirectory = repoRelativeProjectDirectory; + + while (!string.IsNullOrEmpty(currentDirectory)) + { + directories.Push(Path.GetFileName(currentDirectory)); + currentDirectory = Path.GetDirectoryName(currentDirectory); + } + + GitObjectId tree = commit.Tree; + string? searchDirectory = string.Empty; + string? parentDirectory = null; + + VersionOptions? finalResult = null; + actualDirectory = null; + + while (tree != GitObjectId.Empty) + { + var versionTxtBlob = this.Context.Repository.GetTreeEntry(tree, TxtFileNameBytes); + if (versionTxtBlob != GitObjectId.Empty) + { + if (blobVersionCache is null || !blobVersionCache.TryGetValue(versionTxtBlob, out VersionOptions? result)) + { + result = TryReadVersionFile(new StreamReader(this.Context.Repository.GetObjectBySha(versionTxtBlob, "blob")!)); + if (blobVersionCache is object) + { + result?.Freeze(); + blobVersionCache.Add(versionTxtBlob, result); + } + } + + if (result is object) + { + finalResult = result; + actualDirectory = searchDirectory; + } + } + + var versionJsonBlob = this.Context.Repository.GetTreeEntry(tree, JsonFileNameBytes); + if (versionJsonBlob != GitObjectId.Empty) + { + string? versionJsonContent = null; + if (blobVersionCache is null || !blobVersionCache.TryGetValue(versionJsonBlob, out VersionOptions? result)) + { + using (var sr = new StreamReader(this.Context.Repository.GetObjectBySha(versionJsonBlob, "blob")!)) + { + versionJsonContent = sr.ReadToEnd(); + } + + try + { + result = TryReadVersionJsonContent(versionJsonContent, searchDirectory); + } + catch (FormatException ex) + { + throw new FormatException( + $"Failure while reading {JsonFileName} from commit {this.Context.GitCommitId}. " + + "Fix this commit with rebase if this is an error, or review this doc on how to migrate to Nerdbank.GitVersioning: " + + "https://github.com/dotnet/Nerdbank.GitVersioning/blob/master/doc/migrating.md", ex); + } + + if (blobVersionCache is object) + { + result?.Freeze(); + blobVersionCache.Add(versionJsonBlob, result); + } + } + + if (result?.Inherit ?? false) + { + if (parentDirectory is object) + { + result = this.GetVersion(commit, parentDirectory, blobVersionCache, out string? resultingDirectory); + if (result is object) + { + if (versionJsonContent is null) + { + // We reused a cache VersionOptions, but now we need the actual JSON string. + using (var sr = new StreamReader(this.Context.Repository.GetObjectBySha(versionJsonBlob, "blob")!)) + { + versionJsonContent = sr.ReadToEnd(); + } + } + + if (result.IsFrozen) + { + result = new VersionOptions(result); + } + + JsonConvert.PopulateObject(versionJsonContent, result, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: searchDirectory)); + finalResult = result; + } + else + { + var candidatePath = Path.Combine(searchDirectory, JsonFileName); + throw new InvalidOperationException($"\"{candidatePath}\" inherits from a parent directory version.json file but none exists."); + } + } + else + { + var candidatePath = Path.Combine(searchDirectory, JsonFileName); + throw new InvalidOperationException($"\"{candidatePath}\" inherits from a parent directory version.json file but none exists."); + } + } + + if (result is object) + { + actualDirectory = searchDirectory; + finalResult = result; + } + } + + + if (directories.Count > 0) + { + var directoryName = directories.Pop(); + tree = this.Context.Repository.GetTreeEntry(tree, GitRepository.Encoding.GetBytes(directoryName)); + parentDirectory = searchDirectory; + searchDirectory = Path.Combine(searchDirectory, directoryName); + } + else + { + tree = GitObjectId.Empty; + parentDirectory = null; + searchDirectory = null; + break; + } + } + + return finalResult; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/DeltaInstruction.cs b/src/NerdBank.GitVersioning/ManagedGit/DeltaInstruction.cs new file mode 100644 index 00000000..149da05a --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/DeltaInstruction.cs @@ -0,0 +1,27 @@ +#nullable enable + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Represents an instruction in a deltified stream. + /// + /// + public struct DeltaInstruction + { + /// + /// Gets or sets the type of the current instruction. + /// + public DeltaInstructionType InstructionType; + + /// + /// If the is , + /// the offset of the base stream to start copying from. + /// + public int Offset; + + /// + /// The number of bytes to copy or insert. + /// + public int Size; + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/DeltaInstructionType.cs b/src/NerdBank.GitVersioning/ManagedGit/DeltaInstructionType.cs new file mode 100644 index 00000000..8373bf82 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/DeltaInstructionType.cs @@ -0,0 +1,21 @@ +#nullable enable + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Enumerates the various instruction types which can be found in a deltafied stream. + /// + /// + public enum DeltaInstructionType + { + /// + /// Instructs the caller to insert a new byte range into the object. + /// + Insert = 0, + + /// + /// Instructs the caller to copy a byte range from the source object. + /// + Copy = 1, + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/DeltaStreamReader.cs b/src/NerdBank.GitVersioning/ManagedGit/DeltaStreamReader.cs new file mode 100644 index 00000000..08ee301e --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/DeltaStreamReader.cs @@ -0,0 +1,173 @@ +#nullable enable + +using System; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Reads delta instructions from a . + /// + /// + public static class DeltaStreamReader + { + /// + /// Reads the next instruction from a . + /// + /// + /// The stream from which to read the instruction. + /// + /// + /// The next instruction if found; otherwise, . + /// + public static DeltaInstruction? Read(Stream stream) + { + int next = stream.ReadByte(); + + if (next == -1) + { + return null; + } + + byte instruction = (byte)next; + + DeltaInstruction value; + value.Offset = 0; + value.Size = 0; + + value.InstructionType = (DeltaInstructionType)((instruction & 0b1000_0000) >> 7); + + if (value.InstructionType == DeltaInstructionType.Insert) + { + value.Size = instruction & 0b0111_1111; + } + else if (value.InstructionType == DeltaInstructionType.Copy) + { + // offset1 + if ((instruction & 0b0000_0001) != 0) + { + value.Offset |= (byte)stream.ReadByte(); + } + + // offset2 + if ((instruction & 0b0000_0010) != 0) + { + value.Offset |= ((byte)stream.ReadByte() << 8); + } + + // offset3 + if ((instruction & 0b0000_0100) != 0) + { + value.Offset |= ((byte)stream.ReadByte() << 16); + } + + // offset4 + if ((instruction & 0b0000_1000) != 0) + { + value.Offset |= ((byte)stream.ReadByte() << 24); + } + + // size1 + if ((instruction & 0b0001_0000) != 0) + { + value.Size = (byte)stream.ReadByte(); + } + + // size2 + if ((instruction & 0b0010_0000) != 0) + { + value.Size |= ((byte)stream.ReadByte() << 8); + } + + // size3 + if ((instruction & 0b0100_0000) != 0) + { + value.Size |= ((byte)stream.ReadByte() << 16); + } + } + + return value; + } + + /// + /// Reads the next instruction from a . + /// + /// + /// The stream from which to read the instruction. + /// + /// + /// The next instruction if found; otherwise, . + /// + public static DeltaInstruction? Read(ref ReadOnlyMemory stream) + { + if (stream.Length == 0) + { + return null; + } + + var span = stream.Span; + int i = 0; + int next = span[i++]; + + byte instruction = (byte)next; + + DeltaInstruction value; + value.Offset = 0; + value.Size = 0; + + value.InstructionType = (DeltaInstructionType)((instruction & 0b1000_0000) >> 7); + + if (value.InstructionType == DeltaInstructionType.Insert) + { + value.Size = instruction & 0b0111_1111; + } + else if (value.InstructionType == DeltaInstructionType.Copy) + { + // offset1 + if ((instruction & 0b0000_0001) != 0) + { + value.Offset |= span[i++]; + } + + // offset2 + if ((instruction & 0b0000_0010) != 0) + { + value.Offset |= (span[i++] << 8); + } + + // offset3 + if ((instruction & 0b0000_0100) != 0) + { + value.Offset |= (span[i++] << 16); + } + + // offset4 + if ((instruction & 0b0000_1000) != 0) + { + value.Offset |= (span[i++] << 24); + } + + // size1 + if ((instruction & 0b0001_0000) != 0) + { + value.Size = span[i++]; + } + + // size2 + if ((instruction & 0b0010_0000) != 0) + { + value.Size |= (span[i++] << 8); + } + + // size3 + if ((instruction & 0b0100_0000) != 0) + { + value.Size |= (span[i++] << 16); + } + } + + stream = stream.Slice(i); + return value; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/FileHelpers.cs b/src/NerdBank.GitVersioning/ManagedGit/FileHelpers.cs new file mode 100644 index 00000000..623539a1 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/FileHelpers.cs @@ -0,0 +1,104 @@ +#nullable enable + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using static PInvoke.Kernel32; +using FileShare = PInvoke.Kernel32.FileShare; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal static class FileHelpers + { + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// + /// Opens the file with a given path, if it exists. + /// + /// The path to the file. + /// The stream to open to, if the file exists. + /// if the file exists; otherwise . + internal static bool TryOpen(string path, out FileStream? stream) + { + if (IsWindows) + { + var handle = CreateFile(path, ACCESS_MASK.GenericRight.GENERIC_READ, FileShare.FILE_SHARE_READ, (SECURITY_ATTRIBUTES?)null, CreationDisposition.OPEN_EXISTING, CreateFileFlags.FILE_ATTRIBUTE_NORMAL, SafeObjectHandle.Null); + + if (!handle.IsInvalid) + { + var fileHandle = new SafeFileHandle(handle.DangerousGetHandle(), ownsHandle: true); + handle.SetHandleAsInvalid(); + stream = new FileStream(fileHandle, System.IO.FileAccess.Read); + return true; + } + else + { + stream = null; + return false; + } + } + else + { + if (!File.Exists(path)) + { + stream = null; + return false; + } + + stream = File.OpenRead(path); + return true; + } + } + + /// + /// Opens the file with a given path, if it exists. + /// + /// The path to the file, as a null-terminated UTF-16 character array. + /// The stream to open to, if the file exists. + /// if the file exists; otherwise . + internal static unsafe bool TryOpen(ReadOnlySpan path, [NotNullWhen(true)] out FileStream? stream) + { + if (IsWindows) + { + var handle = CreateFile(path, ACCESS_MASK.GenericRight.GENERIC_READ, FileShare.FILE_SHARE_READ, null, CreationDisposition.OPEN_EXISTING, CreateFileFlags.FILE_ATTRIBUTE_NORMAL, SafeObjectHandle.Null); + + if (!handle.IsInvalid) + { + var fileHandle = new SafeFileHandle(handle.DangerousGetHandle(), ownsHandle: true); + handle.SetHandleAsInvalid(); + stream = new FileStream(fileHandle, System.IO.FileAccess.Read); + return true; + } + else + { + stream = null; + return false; + } + } + else + { + // Make sure to trim the trailing \0 + string fullPath = GetUtf16String(path.Slice(0, path.Length - 1)); + + if (!File.Exists(fullPath)) + { + stream = null; + return false; + } + + stream = File.OpenRead(fullPath); + return true; + } + } + + private static unsafe string GetUtf16String(ReadOnlySpan chars) + { + fixed (char* pChars = chars) + { + return new string(pChars, 0, chars.Length); + } + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitCommit.cs b/src/NerdBank.GitVersioning/ManagedGit/GitCommit.cs new file mode 100644 index 00000000..b9448a42 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitCommit.cs @@ -0,0 +1,175 @@ +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Represents a Git commit, as stored in the Git object database. + /// + public struct GitCommit : IEquatable + { + /// + /// Gets or sets the of the file tree which represents directory + /// structure of the repository at the time of the commit. + /// + public GitObjectId Tree { get; set; } + + /// + /// Gets or sets a which uniquely identifies the . + /// + public GitObjectId Sha { get; set; } + + /// + /// Gets or sets the first parent of this commit. + /// + public GitObjectId? FirstParent { get; set; } + + /// + /// Gets or sets the second parent of this commit. + /// + public GitObjectId? SecondParent { get; set; } + + /// + /// Gets or sets additional parents (3rd parent and on) of this commit, if any. + /// + public List? AdditionalParents { get; set; } + + /// + /// Gets an enumerator for parents of this commit. + /// + public ParentEnumerable Parents => new ParentEnumerable(this); + + /// + /// Gets or sets the author of this commit. + /// + public GitSignature? Author { get; set; } + + /// + public override bool Equals(object? obj) + { + if (obj is GitCommit) + { + return this.Equals((GitCommit)obj); + } + + return false; + } + + /// + public bool Equals(GitCommit other) + { + return this.Sha.Equals(other.Sha); + } + + /// + public static bool operator ==(GitCommit left, GitCommit right) + { + return Equals(left, right); + } + + /// + public static bool operator !=(GitCommit left, GitCommit right) + { + return !Equals(left, right); + } + + /// + public override int GetHashCode() + { + return this.Sha.GetHashCode(); + } + + /// + public override string ToString() + { + return $"Git Commit: {this.Sha}"; + } + + /// + /// An enumerable for parents of a commit. + /// + public struct ParentEnumerable : IEnumerable + { + private readonly GitCommit owner; + + /// + /// Initializes an instance of the struct. + /// + /// The commit whose parents are to be enumerated. + public ParentEnumerable(GitCommit owner) + { + this.owner = owner; + } + + /// + public IEnumerator GetEnumerator() => new ParentEnumerator(this.owner); + + /// + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + /// + /// An enumerator for a commit's parents. + /// + public struct ParentEnumerator : IEnumerator + { + private readonly GitCommit owner; + + private int position; + + /// + /// Initializes an instance of the struct. + /// + /// The commit whose parents are to be enumerated. + public ParentEnumerator(GitCommit owner) + { + this.owner = owner; + this.position = -1; + } + + /// + public GitObjectId Current + { + get + { + if (this.position < 0) + { + throw new InvalidOperationException("Call MoveNext first."); + } + + return this.position switch + { + 0 => this.owner.FirstParent ?? throw new InvalidOperationException("No more elements."), + 1 => this.owner.SecondParent ?? throw new InvalidOperationException("No more elements."), + _ => this.owner.AdditionalParents?[this.position - 2] ?? throw new InvalidOperationException("No more elements."), + }; + } + } + + /// + object IEnumerator.Current => this.Current; + + /// + public void Dispose() + { + } + + /// + public bool MoveNext() + { + return ++this.position switch + { + 0 => this.owner.FirstParent.HasValue, + 1 => this.owner.SecondParent.HasValue, + _ => this.owner.AdditionalParents?.Count >= this.position - 2, + }; + } + + /// + public void Reset() => this.position = -1; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs new file mode 100644 index 00000000..25b22e49 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitCommitReader.cs @@ -0,0 +1,186 @@ +#nullable enable + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Reads a object. + /// + public static class GitCommitReader + { + private static readonly byte[] TreeStart = GitRepository.Encoding.GetBytes("tree "); + private static readonly byte[] ParentStart = GitRepository.Encoding.GetBytes("parent "); + private static readonly byte[] AuthorStart = GitRepository.Encoding.GetBytes("author "); + + private const int TreeLineLength = 46; + private const int ParentLineLength = 48; + + /// + /// Reads a object from a . + /// + /// + /// A which contains the in its text representation. + /// + /// + /// The of the commit. + /// + /// + /// A value indicating whether to populate the field. + /// + /// + /// The . + /// + public static GitCommit Read(Stream stream, GitObjectId sha, bool readAuthor = false) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + byte[] buffer = ArrayPool.Shared.Rent((int)stream.Length); + + try + { + Span span = buffer.AsSpan(0, (int)stream.Length); + stream.ReadAll(span); + + return Read(span, sha, readAuthor); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Reads a object from a . + /// + /// + /// A which contains the in its text representation. + /// + /// + /// The of the commit. + /// + /// + /// A value indicating whether to populate the field. + /// + /// + /// The . + /// + public static GitCommit Read(ReadOnlySpan commit, GitObjectId sha, bool readAuthor = false) + { + var buffer = commit; + + var tree = ReadTree(buffer.Slice(0, TreeLineLength)); + + buffer = buffer.Slice(TreeLineLength); + + GitObjectId? firstParent = null, secondParent = null; + List? additionalParents = null; + List parents = new List(); + while (TryReadParent(buffer, out GitObjectId parent)) + { + if (!firstParent.HasValue) + { + firstParent = parent; + } + else if (!secondParent.HasValue) + { + secondParent = parent; + } + else + { + additionalParents ??= new List(); + additionalParents.Add(parent); + } + + buffer = buffer.Slice(ParentLineLength); + } + + GitSignature signature = default; + + if (readAuthor && !TryReadAuthor(buffer, out signature)) + { + throw new GitException(); + } + + return new GitCommit() + { + Sha = sha, + FirstParent = firstParent, + SecondParent = secondParent, + AdditionalParents = additionalParents, + Tree = tree, + Author = readAuthor ? signature : (GitSignature?)null, + }; + } + + private static GitObjectId ReadTree(ReadOnlySpan line) + { + // Format: tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579\n + // 47 bytes: + // tree: 5 bytes + // space: 1 byte + // hash: 40 bytes + // \n: 1 byte + Debug.Assert(line.Slice(0, TreeStart.Length).SequenceEqual(TreeStart)); + Debug.Assert(line[TreeLineLength - 1] == (byte)'\n'); + + return GitObjectId.ParseHex(line.Slice(TreeStart.Length, 40)); + } + + private static bool TryReadParent(ReadOnlySpan line, out GitObjectId parent) + { + // Format: "parent ef079ebcca375f6fd54aa0cb9f35e3ecc2bb66e7\n" + parent = GitObjectId.Empty; + + if (!line.Slice(0, ParentStart.Length).SequenceEqual(ParentStart)) + { + return false; + } + + if (line[ParentLineLength - 1] != (byte)'\n') + { + return false; + } + + parent = GitObjectId.ParseHex(line.Slice(ParentStart.Length, 40)); + return true; + } + + private static bool TryReadAuthor(ReadOnlySpan line, out GitSignature signature) + { + signature = default; + + if (!line.Slice(0, AuthorStart.Length).SequenceEqual(AuthorStart)) + { + return false; + } + + line = line.Slice(AuthorStart.Length); + + int emailStart = line.IndexOf((byte)'<'); + int emailEnd = line.IndexOf((byte)'>'); + var lineEnd = line.IndexOf((byte)'\n'); + + var name = line.Slice(0, emailStart - 1); + var email = line.Slice(emailStart + 1, emailEnd - emailStart - 1); + var time = line.Slice(emailEnd + 2, lineEnd - emailEnd - 2); + + signature.Name = GitRepository.GetString(name); + signature.Email = GitRepository.GetString(email); + + var offsetStart = time.IndexOf((byte)' '); + var ticks = long.Parse(GitRepository.GetString(time.Slice(0, offsetStart))); + signature.Date = DateTimeOffset.FromUnixTimeSeconds(ticks); + + return true; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs b/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs new file mode 100644 index 00000000..b18d04a9 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitObjectId.cs @@ -0,0 +1,246 @@ +#nullable enable + +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// A identifies an object stored in the Git repository. The + /// of an object is the SHA-1 hash of the contents of that + /// object. + /// + /// . + public unsafe struct GitObjectId : IEquatable + { + private const string hexDigits = "0123456789abcdef"; + private readonly static byte[] hexBytes = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f' }; + private const int NativeSize = 20; + private fixed byte value[NativeSize]; + private string? sha; + + /// + /// Gets the 20 byte ID of this object as a span from the field. + /// + private Span Value + { + get + { + fixed (byte* value = this.value) + { + return new Span(value, NativeSize); + } + } + } + + private static readonly byte[] ReverseHexDigits = BuildReverseHexDigits(); + + /// + /// Gets a which represents an empty . + /// + public static GitObjectId Empty => default(GitObjectId); + + /// + /// Parses a which contains the + /// as a sequence of byte values. + /// + /// + /// The as a sequence of byte values. Must be exactly 20 bytes in length. + /// + /// + /// A . + /// + public static GitObjectId Parse(ReadOnlySpan value) + { + Debug.Assert(value.Length == 20); + + GitObjectId objectId = new GitObjectId(); + value.CopyTo(objectId.Value); + return objectId; + } + + /// + /// Parses a which contains the hexadecimal representation of a + /// . + /// + /// + /// A which contains the hexadecimal representation of the + /// . + /// + /// + /// A . + /// + public static GitObjectId Parse(string value) + { + Debug.Assert(value.Length == 40); + + GitObjectId objectId = new GitObjectId(); + Span bytes = objectId.Value; + + for (int i = 0; i < value.Length; i++) + { + int c1 = ReverseHexDigits[value[i++] - '0'] << 4; + int c2 = ReverseHexDigits[value[i] - '0']; + + bytes[i >> 1] = (byte)(c1 + c2); + } + + objectId.sha = value.ToLower(); + return objectId; + } + + /// + /// Parses a which contains the hexadecimal representation of a + /// . + /// + /// + /// A which contains the hexadecimal representation of the + /// encoded in ASCII. + /// + /// + /// A . + /// + public static GitObjectId ParseHex(ReadOnlySpan value) + { + Debug.Assert(value.Length == 40); + + GitObjectId objectId = new GitObjectId(); + Span bytes = objectId.Value; + + for (int i = 0; i < value.Length; i++) + { + int c1 = ReverseHexDigits[value[i++] - '0'] << 4; + int c2 = ReverseHexDigits[value[i] - '0']; + + bytes[i >> 1] = (byte)(c1 + c2); + } + + return objectId; + } + + private static byte[] BuildReverseHexDigits() + { + var bytes = new byte['f' - '0' + 1]; + + for (int i = 0; i < 10; i++) + { + bytes[i] = (byte)i; + } + + for (int i = 10; i < 16; i++) + { + bytes[i + 'a' - '0' - 0x0a] = (byte)i; + bytes[i + 'A' - '0' - 0x0a] = (byte)i; + } + + return bytes; + } + + /// + public override bool Equals(object? obj) + { + if (obj is GitObjectId) + { + return this.Equals((GitObjectId)obj); + } + + return false; + } + + /// + public bool Equals(GitObjectId other) => this.Value.SequenceEqual(other.Value); + + /// + public static bool operator ==(GitObjectId left, GitObjectId right) => Equals(left, right); + + /// + public static bool operator !=(GitObjectId left, GitObjectId right) => !Equals(left, right); + + /// + public override int GetHashCode() => BinaryPrimitives.ReadInt32LittleEndian(this.Value.Slice(0, 4)); + + /// + /// Gets a which represents the first two bytes of this . + /// + /// + /// A which represents the first two bytes of this . + /// + public ushort AsUInt16() => BinaryPrimitives.ReadUInt16LittleEndian(this.Value.Slice(0, 2)); + + /// + /// Returns the SHA1 hash of this object. + /// + public override string ToString() + { + if (this.sha == null) + { + this.sha = this.CreateString(0, 20); + } + + return this.sha; + } + + private string CreateString(int start, int length) + { + // Inspired byte http://stackoverflow.com/questions/623104/c-byte-to-hex-string/3974535#3974535 + int lengthInNibbles = length * 2; + var c = new char[lengthInNibbles]; + + for (int i = 0; i < (lengthInNibbles & -2); i++) + { + int index0 = +i >> 1; + var b = ((byte)(this.value[start + index0] >> 4)); + c[i++] = hexDigits[b]; + + b = ((byte)(this.value[start + index0] & 0x0F)); + c[i] = hexDigits[b]; + } + + return new string(c); + } + + /// + /// Encodes a portion of this as hex. + /// + /// + /// The index of the first byte of this to start copying. + /// + /// + /// The number of bytes of this to copy. + /// + /// The buffer that receives the hex characters. It must be at least twice as long as . + /// + /// This method is used to populate file paths as byte* objects which are passed to UTF-16-based + /// Windows APIs. + /// + public void CopyAsHex(int start, int length, Span chars) + { + Span bytes = MemoryMarshal.Cast(chars); + + // Inspired by http://stackoverflow.com/questions/623104/c-byte-to-hex-string/3974535#3974535 + int lengthInNibbles = length * 2; + + for (int i = 0; i < (lengthInNibbles & -2); i++) + { + int index0 = +i >> 1; + var b = ((byte)(this.value[start + index0] >> 4)); + bytes[2 * i + 1] = 0; + bytes[2 * i++] = hexBytes[b]; + + b = ((byte)(this.value[start + index0] & 0x0F)); + bytes[2 * i + 1] = 0; + bytes[2 * i] = hexBytes[b]; + } + } + + /// + /// Copies the byte representation of this to a . + /// + /// + /// The memory to which to copy this . + /// + public void CopyTo(Span value) => this.Value.CopyTo(value); + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitObjectStream.cs b/src/NerdBank.GitVersioning/ManagedGit/GitObjectStream.cs new file mode 100644 index 00000000..da8c5201 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitObjectStream.cs @@ -0,0 +1,74 @@ +#nullable enable + +using System; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// A which reads data stored in the Git object store. The data is stored + /// as a gz-compressed stream, and is prefixed with the object type and data length. + /// + public class GitObjectStream : ZLibStream + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The from which to read data. + /// + /// + /// The expected object type of the git object. + /// +#pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable. ObjectType is assigned in ReadObjectTypeAndLength. + public GitObjectStream(Stream stream, string objectType) +#pragma warning restore CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable. + : base(stream, -1) + { + this.ReadObjectTypeAndLength(objectType); + } + + /// + /// Gets the object type of this Git object. + /// + public string ObjectType { get; private set; } + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + private void ReadObjectTypeAndLength(string objectType) + { + Span buffer = stackalloc byte[128]; + this.Read(buffer.Slice(0, objectType.Length + 1)); + + var actualObjectType = GitRepository.GetString(buffer.Slice(0, objectType.Length)); + this.ObjectType = actualObjectType; + + int headerLength = 0; + long length = 0; + + while (headerLength < buffer.Length) + { + this.Read(buffer.Slice(headerLength, 1)); + + if (buffer[headerLength] == 0) + { + break; + } + + // Direct conversion from ASCII to int + length = (10 * length) + (buffer[headerLength] - (byte)'0'); + + headerLength += 1; + } + + this.Initialize(length); + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs new file mode 100644 index 00000000..3d6de361 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPack.cs @@ -0,0 +1,292 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Linq; +using System.Text; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Supports retrieving objects from a Git pack file. + /// + public class GitPack : IDisposable + { + /// + /// A delegate for methods which fetch objects from the Git object store. + /// + /// + /// The Git object ID of the object to fetch. + /// + /// + /// The object type of the object to fetch. + /// + /// + /// A which represents the requested object. + /// + public delegate Stream? GetObjectFromRepositoryDelegate(GitObjectId sha, string objectType); + + private readonly Func packStream; + private readonly Lazy indexStream; + private readonly GitPackCache cache; + private MemoryMappedFile packFile; + private MemoryMappedViewAccessor accessor; + + // Maps GitObjectIds to offets in the git pack. + private readonly Dictionary offsets = new Dictionary(); + + // A histogram which tracks the objects which have been retrieved from this GitPack. The key is the offset + // of the object. Used to get some insights in usage patterns. +#if DEBUG && !NETSTANDARD + private readonly Dictionary histogram = new Dictionary(); +#endif + + private Lazy indexReader; + + // Operating on git packfiles can potentially open a lot of streams which point to the pack file. For example, + // deltafied objects can have base objects which are in turn delafied. Opening and closing these streams has + // become a performance bottleneck. This is mitigated by pooling streams (i.e. reusing the streams after they + // are closed by the caller). + private readonly Queue pooledStreams = new Queue(); + + /// + /// Initializes a new instance of the class. + /// + /// + /// The repository to which this pack file belongs. + /// + /// + /// The name of the pack file. + /// + internal GitPack(GitRepository repository, string name) + : this( + repository.GetObjectBySha, + indexPath: Path.Combine(repository.ObjectDirectory, "pack", $"{name}.idx"), + packPath: Path.Combine(repository.ObjectDirectory, "pack", $"{name}.pack")) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// A delegate which fetches objects from the Git object store. + /// + /// + /// The full path to the index file. + /// + /// + /// The full path to the pack file. + /// + /// + /// A which is used to cache objects which operate + /// on the pack file. + /// + public GitPack(GetObjectFromRepositoryDelegate getObjectFromRepositoryDelegate, string indexPath, string packPath, GitPackCache? cache = null) + : this(getObjectFromRepositoryDelegate, new Lazy(() => File.OpenRead(indexPath)), () => File.OpenRead(packPath), cache) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// A delegate which fetches objects from the Git object store. + /// + /// + /// A function which creates a new which provides read-only + /// access to the index file. + /// + /// + /// A function which creates a new which provides read-only + /// access to the pack file. + /// + /// + /// A which is used to cache objects which operate + /// on the pack file. + /// + public GitPack(GetObjectFromRepositoryDelegate getObjectFromRepositoryDelegate, Lazy indexStream, Func packStream, GitPackCache? cache = null) + { + this.GetObjectFromRepository = getObjectFromRepositoryDelegate ?? throw new ArgumentNullException(nameof(getObjectFromRepositoryDelegate)); + this.indexReader = new Lazy(this.OpenIndex); + this.packStream = packStream ?? throw new ArgumentException(nameof(packStream)); + this.indexStream = indexStream ?? throw new ArgumentNullException(nameof(indexStream)); + this.cache = cache ?? new GitPackMemoryCache(); + + this.packFile = MemoryMappedFile.CreateFromFile(this.packStream(), mapName: null, 0, MemoryMappedFileAccess.Read, HandleInheritability.None, leaveOpen: false); + this.accessor = this.packFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + } + + /// + /// Gets a delegate which fetches objects from the Git object store. + /// + public GetObjectFromRepositoryDelegate GetObjectFromRepository { get; private set; } + + /// + /// Finds a git object using a partial object ID. + /// + /// + /// A partial object ID. + /// + /// + /// + /// If found, a full object ID which matches the partial object ID. + /// Otherwise, . + /// + public GitObjectId? Lookup(Span objectId, bool endsWithHalfByte = false) + { + (var _, var actualObjectId) = this.indexReader.Value.GetOffset(objectId, endsWithHalfByte); + return actualObjectId; + } + + /// + /// Attempts to retrieve a Git object from this Git pack. + /// + /// + /// The Git object Id of the object to retrieve. + /// + /// + /// The object type of the object to retrieve. + /// + /// + /// If found, receives a which represents the object. + /// + /// + /// if the object was found; otherwise, . + /// + public bool TryGetObject(GitObjectId objectId, string objectType, out Stream? value) + { + var offset = this.GetOffset(objectId); + + if (offset == null) + { + value = null; + return false; + } + else + { + value = this.GetObject(offset.Value, objectType); + return true; + } + } + + /// + /// Gets a Git object at a specific offset. + /// + /// + /// The offset of the Git object, relative to the pack file. + /// + /// + /// The object type of the object to retrieve. + /// + /// + /// A which represents the object. + /// + public Stream GetObject(long offset, string objectType) + { +#if DEBUG && !NETSTANDARD + if (!this.histogram.TryAdd(offset, 1)) + { + this.histogram[offset] += 1; + } +#endif + + if (this.cache.TryOpen(offset, out Stream? stream)) + { + return stream!; + } + + GitPackObjectType packObjectType; + + switch (objectType) + { + case "commit": + packObjectType = GitPackObjectType.OBJ_COMMIT; + break; + + case "tree": + packObjectType = GitPackObjectType.OBJ_TREE; + break; + + case "blob": + packObjectType = GitPackObjectType.OBJ_BLOB; + break; + + default: + throw new GitException($"The object type '{objectType}' is not supported by the {nameof(GitPack)} class."); + } + + var packStream = this.GetPackStream(); + Stream objectStream = GitPackReader.GetObject(this, packStream, offset, objectType, packObjectType); + + return this.cache.Add(offset, objectStream); + } + + /// + /// Writes cache statistics to a . + /// + /// + /// A to which the cache statistics are written. + /// + public void GetCacheStatistics(StringBuilder builder) + { + builder.AppendLine($"Git Pack:"); + +#if DEBUG && !NETSTANDARD + int histogramCount = 25; + builder.AppendLine($"Top {histogramCount} / {this.histogram.Count} items:"); + + foreach (var item in this.histogram.OrderByDescending(v => v.Value).Take(25)) + { + builder.AppendLine($" {item.Key}: {item.Value}"); + } + + builder.AppendLine(); +#endif + + this.cache.GetCacheStatistics(builder); + } + + /// + public void Dispose() + { + if (this.indexReader.IsValueCreated) + { + this.indexReader.Value.Dispose(); + } + + this.accessor.Dispose(); + this.packFile.Dispose(); + } + + private long? GetOffset(GitObjectId objectId) + { + if (this.offsets.TryGetValue(objectId, out long cachedOffset)) + { + return cachedOffset; + } + + var indexReader = this.indexReader.Value; + var offset = indexReader.GetOffset(objectId); + + if (offset != null) + { + this.offsets.Add(objectId, offset.Value); + } + + return offset; + } + + private Stream GetPackStream() + { + return new MemoryMappedStream(this.accessor); + } + + private GitPackIndexReader OpenIndex() + { + return new GitPackIndexMappedReader(this.indexStream.Value); + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackCache.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackCache.cs new file mode 100644 index 00000000..71db8965 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackCache.cs @@ -0,0 +1,55 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Represents a cache in which objects retrieved from a + /// are cached. Caching these objects can be of interest, because retrieving + /// data from a can be potentially expensive: the data is + /// compressed and can be deltified. + /// + public abstract class GitPackCache + { + /// + /// Attempts to retrieve a Git object from cache. + /// + /// + /// The offset of the Git object in the Git pack. + /// + /// + /// A which will be set to the cached Git object. + /// + /// + /// if the object was found in cache; otherwise, + /// . + /// + public abstract bool TryOpen(long offset, [NotNullWhen(true)] out Stream? stream); + + /// + /// Gets statistics about the cache usage. + /// + /// + /// A to which to write the statistics. + /// + public abstract void GetCacheStatistics(StringBuilder builder); + + /// + /// Adds a Git object to this cache. + /// + /// + /// The offset of the Git object in the Git pack. + /// + /// + /// A which represents the object to add. This stream + /// will be copied to the cache. + /// + /// + /// A which represents the cached entry. + /// + public abstract Stream Add(long offset, Stream stream); + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackDeltafiedStream.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackDeltafiedStream.cs new file mode 100644 index 00000000..da4eefa2 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackDeltafiedStream.cs @@ -0,0 +1,206 @@ +#nullable enable + +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Reads data from a deltafied object. + /// + /// + public class GitPackDeltafiedStream : Stream + { + private readonly long length; + private long position; + + private readonly Stream baseStream; + private readonly Stream deltaStream; + + private DeltaInstruction? current; + private int offset; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The base stream to which the deltas are applied. + /// + /// + /// A which contains a sequence of s. + /// + public GitPackDeltafiedStream(Stream baseStream, Stream deltaStream) + { + this.baseStream = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + this.deltaStream = deltaStream ?? throw new ArgumentNullException(nameof(deltaStream)); + + int baseObjectlength = deltaStream.ReadMbsInt(); + this.length = deltaStream.ReadMbsInt(); + } + + /// + /// Gets the base stream to which the deltas are applied. + /// + public Stream BaseStream => this.baseStream; + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => false; + + /// + public override long Length => this.length; + + /// + public override long Position + { + get => this.position; + set => throw new NotImplementedException(); + } + +#if NETSTANDARD + /// + /// Reads a sequence of bytes from the current and advances the position + /// within the stream by the number of bytes read. + /// + /// + /// A region of memory. When this method returns, the contents of this region are replaced by the bytes + /// read from the current source. + /// + /// + /// The total number of bytes read into the buffer. This can be less than the number of bytes allocated + /// in the buffer if that many bytes are not currently available, or zero (0) if the end of the stream + /// has been reached. + /// + public int Read(Span span) +#else + /// + public override int Read(Span span) +#endif + { + int read = 0; + int canRead; + int didRead; + + while (read < span.Length && this.TryGetInstruction(out DeltaInstruction instruction)) + { + var source = instruction.InstructionType == DeltaInstructionType.Copy ? this.baseStream : this.deltaStream; + + Debug.Assert(instruction.Size > this.offset); + canRead = Math.Min(span.Length - read, instruction.Size - this.offset); + didRead = source.Read(span.Slice(read, canRead)); + + Debug.Assert(didRead != 0); + read += didRead; + this.offset += didRead; + } + + this.position += read; + Debug.Assert(read <= span.Length); + return read; + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + return this.Read(buffer.AsSpan(offset, count)); + } + + /// + public override void Flush() + { + throw new NotImplementedException(); + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + if (origin == SeekOrigin.Begin && offset == this.position) + { + return this.position; + } + + if (origin == SeekOrigin.Current && offset == 0) + { + return this.position; + } + + if (origin == SeekOrigin.Begin && offset > this.position) + { + // We can optimise this by skipping over instructions rather than executing them + int length = (int)(offset - this.position); + + byte[] buffer = ArrayPool.Shared.Rent(length); + this.Read(buffer, 0, length); + ArrayPool.Shared.Return(buffer); + return this.position; + } + else + { + throw new NotImplementedException(); + } + } + + /// + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + + /// + protected override void Dispose(bool disposing) + { + this.deltaStream.Dispose(); + this.baseStream.Dispose(); + } + + private bool TryGetInstruction(out DeltaInstruction instruction) + { + if (this.current != null && this.offset < this.current.Value.Size) + { + instruction = this.current.Value; + return true; + } + + this.current = DeltaStreamReader.Read(this.deltaStream); + + if (this.current == null) + { + instruction = default; + return false; + } + + instruction = this.current.Value; + + switch (instruction.InstructionType) + { + case DeltaInstructionType.Copy: + this.baseStream.Seek(instruction.Offset, SeekOrigin.Begin); + Debug.Assert(this.baseStream.Position == instruction.Offset); + this.offset = 0; + break; + + case DeltaInstructionType.Insert: + this.offset = 0; + break; + + default: + throw new GitException(); + } + + return true; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackIndexMappedReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackIndexMappedReader.cs new file mode 100644 index 00000000..8f895ac1 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackIndexMappedReader.cs @@ -0,0 +1,166 @@ +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// A which uses a memory-mapped file to read from the index. + /// + /// + public unsafe class GitPackIndexMappedReader : GitPackIndexReader + { + private readonly MemoryMappedFile file; + private readonly MemoryMappedViewAccessor accessor; + + // The fanout table consists of + // 256 4-byte network byte order integers. + // The N-th entry of this table records the number of objects in the corresponding pack, + // the first byte of whose object name is less than or equal to N. + private readonly int[] fanoutTable = new int[257]; + + private byte* ptr; + private bool initialized; + + /// + /// Initializes a new instance of the class. + /// + /// + /// A which points to the index file. + /// + public GitPackIndexMappedReader(FileStream stream) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + this.file = MemoryMappedFile.CreateFromFile(stream, mapName: null, capacity: 0, MemoryMappedFileAccess.Read, HandleInheritability.None, leaveOpen: false); + this.accessor = this.file.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + this.accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref this.ptr); + } + + private ReadOnlySpan Value + { + get + { + return new ReadOnlySpan(this.ptr, (int)this.accessor.Capacity); + } + } + + /// + public override (long?, GitObjectId?) GetOffset(Span objectName, bool endsWithHalfByte = false) + { + this.Initialize(); + + var packStart = this.fanoutTable[objectName[0]]; + var packEnd = this.fanoutTable[objectName[0] + 1]; + var objectCount = this.fanoutTable[256]; + + // The fanout table is followed by a table of sorted 20-byte SHA-1 object names. + // These are packed together without offset values to reduce the cache footprint of the binary search for a specific object name. + + // The object names start at: 4 (header) + 4 (version) + 256 * 4 (fanout table) + 20 * (packStart) + // and end at 4 (header) + 4 (version) + 256 * 4 (fanout table) + 20 * (packEnd) + + var i = 0; + var order = 0; + + var tableSize = 20 * (packEnd - packStart + 1); + var table = this.Value.Slice(4 + 4 + 256 * 4 + 20 * packStart, tableSize); + + int originalPackStart = packStart; + + packEnd -= originalPackStart; + packStart = 0; + + while (packStart <= packEnd) + { + i = (packStart + packEnd) / 2; + + ReadOnlySpan comparand = table.Slice(20 * i, objectName.Length); + if (endsWithHalfByte) + { + // Copy out the value to be checked so we can zero out the last four bits, + // so that it matches the last 4 bits of the objectName that isn't supposed to be compared. + Span buffer = stackalloc byte[20]; + comparand.CopyTo(buffer); + buffer[objectName.Length - 1] &= 0xf0; + order = buffer.Slice(0, objectName.Length).SequenceCompareTo(objectName); + } + else + { + order = comparand.SequenceCompareTo(objectName); + } + + if (order < 0) + { + packStart = i + 1; + } + else if (order > 0) + { + packEnd = i - 1; + } + else + { + break; + } + } + + if (order != 0) + { + return (null, null); + } + + // Get the offset value. It's located at: + // 4 (header) + 4 (version) + 256 * 4 (fanout table) + 20 * objectCount (SHA1 object name table) + 4 * objectCount (CRC32) + 4 * i (offset values) + int offsetTableStart = 4 + 4 + 256 * 4 + 20 * objectCount + 4 * objectCount; + var offsetBuffer = this.Value.Slice(offsetTableStart + 4 * (i + originalPackStart), 4); + var offset = BinaryPrimitives.ReadUInt32BigEndian(offsetBuffer); + + if (offsetBuffer[0] < 128) + { + return (offset, GitObjectId.Parse(table.Slice(20 * i, 20))); + } + else + { + // If the first bit of the offset address is set, the offset is stored as a 64-bit value in the table of 8-byte offset entries, + // which follows the table of 4-byte offset entries: "large offsets are encoded as an index into the next table with the msbit set." + offset = offset & 0x7FF; + + offsetBuffer = this.Value.Slice(offsetTableStart + 4 * objectCount + 8 * (int)offset, 8); + var offset64 = BinaryPrimitives.ReadInt64BigEndian(offsetBuffer); + return (offset64, GitObjectId.Parse(table.Slice(20 * i, 20))); + } + } + + /// + public override void Dispose() + { + this.accessor.Dispose(); + this.file.Dispose(); + } + + private void Initialize() + { + if (!this.initialized) + { + var value = this.Value; + + var header = value.Slice(0, 4); + var version = BinaryPrimitives.ReadInt32BigEndian(value.Slice(4, 4)); + Debug.Assert(header.SequenceEqual(Header)); + Debug.Assert(version == 2); + + for (int i = 1; i <= 256; i++) + { + this.fanoutTable[i] = BinaryPrimitives.ReadInt32BigEndian(value.Slice(4 + 4 * i, 4)); + } + + this.initialized = true; + } + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackIndexReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackIndexReader.cs new file mode 100644 index 00000000..d11be037 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackIndexReader.cs @@ -0,0 +1,50 @@ +using System; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Base class for classes which support reading data stored in a Git Pack file. + /// + /// + public abstract class GitPackIndexReader : IDisposable + { + /// + /// The header of the index file. + /// + protected static readonly byte[] Header = new byte[] { 0xff, 0x74, 0x4f, 0x63 }; + + /// + /// Gets the offset of a Git object in the index file. + /// + /// + /// The Git object Id of the Git object for which to get the offset. + /// + /// + /// If found, the offset of the Git object in the index file; otherwise, + /// . + /// + public long? GetOffset(GitObjectId objectId) + { + Span name = stackalloc byte[20]; + objectId.CopyTo(name); + (var offset, var _) = this.GetOffset(name); + return offset; + } + + /// + /// Gets the offset of a Git object in the index file. + /// + /// + /// A partial or full Git object id, in its binary representation. + /// + /// if ends with a byte whose last 4 bits are all zeros and not intended for inclusion in the search; otherwise. + /// + /// If found, the offset of the Git object in the index file; otherwise, + /// . + /// + public abstract (long?, GitObjectId?) GetOffset(Span objectId, bool endsWithHalfByte = false); + + /// + public abstract void Dispose(); + } +} \ No newline at end of file diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackMemoryCache.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackMemoryCache.cs new file mode 100644 index 00000000..da9c3347 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackMemoryCache.cs @@ -0,0 +1,37 @@ +#nullable enable + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal class GitPackMemoryCache : GitPackCache + { + private readonly Dictionary cache = new Dictionary(); + + public override Stream Add(long offset, Stream stream) + { + var cacheStream = new GitPackMemoryCacheStream(stream); + this.cache.Add(offset, cacheStream); + return cacheStream; + } + + public override bool TryOpen(long offset, [NotNullWhen(true)] out Stream? stream) + { + if (this.cache.TryGetValue(offset, out stream)) + { + stream.Seek(0, SeekOrigin.Begin); + return true; + } + + return false; + } + + public override void GetCacheStatistics(StringBuilder builder) + { + builder.AppendLine($"{this.cache.Count} items in cache"); + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackMemoryCacheStream.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackMemoryCacheStream.cs new file mode 100644 index 00000000..59fe2836 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackMemoryCacheStream.cs @@ -0,0 +1,110 @@ +#nullable enable + +using System; +using System.Buffers; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal class GitPackMemoryCacheStream : Stream + { + private Stream stream; + private readonly MemoryStream cacheStream = new MemoryStream(); + private long length; + + public GitPackMemoryCacheStream(Stream stream) + { + this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); + this.length = this.stream.Length; + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => this.length; + + public override long Position + { + get => this.cacheStream.Position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + throw new NotSupportedException(); + } + +#if NETSTANDARD + public int Read(Span buffer) +#else + /// + public override int Read(Span buffer) +#endif + { + if (this.cacheStream.Length < this.length + && this.cacheStream.Position + buffer.Length > this.cacheStream.Length) + { + var currentPosition = this.cacheStream.Position; + var toRead = (int)(buffer.Length - this.cacheStream.Length + this.cacheStream.Position); + this.stream.Read(buffer.Slice(0, toRead)); + this.cacheStream.Seek(0, SeekOrigin.End); + this.cacheStream.Write(buffer.Slice(0, toRead)); + this.cacheStream.Seek(currentPosition, SeekOrigin.Begin); + this.DisposeStreamIfRead(); + } + + return this.cacheStream.Read(buffer); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return this.Read(buffer.AsSpan(offset, count)); + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (origin != SeekOrigin.Begin) + { + throw new NotSupportedException(); + } + + if (offset > this.cacheStream.Length) + { + var toRead = (int)(offset - this.cacheStream.Length); + byte[] buffer = ArrayPool.Shared.Rent(toRead); + this.stream.Read(buffer, 0, toRead); + this.cacheStream.Seek(0, SeekOrigin.End); + this.cacheStream.Write(buffer, 0, toRead); + ArrayPool.Shared.Return(buffer); + + this.DisposeStreamIfRead(); + return this.cacheStream.Position; + } + else + { + return this.cacheStream.Seek(offset, origin); + } + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + private void DisposeStreamIfRead() + { + if (this.cacheStream.Length == this.stream.Length) + { + this.stream.Dispose(); + } + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackNullCache.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackNullCache.cs new file mode 100644 index 00000000..0050123d --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackNullCache.cs @@ -0,0 +1,37 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// A no-op implementation of the class. + /// + public class GitPackNullCache : GitPackCache + { + /// + /// Gets the default instance of the class. + /// + public static GitPackNullCache Instance { get; } = new GitPackNullCache(); + + /// + public override Stream Add(long offset, Stream stream) + { + return stream; + } + + /// + public override bool TryOpen(long offset, [NotNullWhen(true)] out Stream? stream) + { + stream = null; + return false; + } + + /// + public override void GetCacheStatistics(StringBuilder builder) + { + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackObjectType.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackObjectType.cs new file mode 100644 index 00000000..e0ff3ef2 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackObjectType.cs @@ -0,0 +1,15 @@ +#nullable enable + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal enum GitPackObjectType + { + Invalid = 0, + OBJ_COMMIT = 1, + OBJ_TREE = 2, + OBJ_BLOB = 3, + OBJ_TAG = 4, + OBJ_OFS_DELTA = 6, + OBJ_REF_DELTA = 7, + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackPooledStream.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackPooledStream.cs new file mode 100644 index 00000000..fb2dca16 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackPooledStream.cs @@ -0,0 +1,104 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// A pooled , which wraps around a + /// which will be returned to a pool + /// instead of actually being closed when is called. + /// + public class GitPackPooledStream : Stream + { + private readonly Stream stream; + private readonly Queue pool; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The which is being pooled. + /// + /// + /// A to which the stream will be returned. + /// + public GitPackPooledStream(Stream stream, Queue pool) + { + this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); + this.pool = pool ?? throw new ArgumentNullException(nameof(pool)); + } + + /// + /// Gets the underlying for this . + /// + public Stream BaseStream => this.stream; + + /// + public override bool CanRead => this.stream.CanRead; + + /// + public override bool CanSeek => this.stream.CanSeek; + + /// + public override bool CanWrite => this.stream.CanWrite; + + /// + public override long Length => this.stream.Length; + + /// + public override long Position + { + get => this.stream.Position; + set => this.stream.Position = value; + } + + /// + public override void Flush() + { + this.stream.Flush(); + } + +#if !NETSTANDARD + /// + public override int Read(Span buffer) + { + return this.stream.Read(buffer); + } +#endif + + /// + public override int Read(byte[] buffer, int offset, int count) + { + return this.stream.Read(buffer, offset, count); + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + return this.stream.Seek(offset, origin); + } + + /// + public override void SetLength(long value) + { + this.stream.SetLength(value); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + /// + protected override void Dispose(bool disposing) + { + this.pool.Enqueue(this); + Debug.WriteLine("Returning stream to pool"); + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitPackReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitPackReader.cs new file mode 100644 index 00000000..30d3bead --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitPackReader.cs @@ -0,0 +1,117 @@ +#nullable enable + +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal static class GitPackReader + { + private static readonly byte[] Signature = GitRepository.Encoding.GetBytes("PACK"); + + public static Stream GetObject(GitPack pack, Stream stream, long offset, string objectType, GitPackObjectType packObjectType) + { + if (pack == null) + { + throw new ArgumentNullException(nameof(pack)); + } + + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + // Read the signature +#if DEBUG + stream.Seek(0, SeekOrigin.Begin); + Span buffer = stackalloc byte[12]; + stream.ReadAll(buffer); + + Debug.Assert(buffer.Slice(0, 4).SequenceEqual(Signature)); + + var versionNumber = BinaryPrimitives.ReadInt32BigEndian(buffer.Slice(4, 4)); + Debug.Assert(versionNumber == 2); + + var numberOfObjects = BinaryPrimitives.ReadInt32BigEndian(buffer.Slice(8, 4)); +#endif + + stream.Seek(offset, SeekOrigin.Begin); + + var (type, decompressedSize) = ReadObjectHeader(stream); + + if (type == GitPackObjectType.OBJ_OFS_DELTA) + { + var baseObjectRelativeOffset = ReadVariableLengthInteger(stream); + var baseObjectOffset = (int)(offset - baseObjectRelativeOffset); + + var deltaStream = new ZLibStream(stream, decompressedSize); + var baseObjectStream = pack.GetObject(baseObjectOffset, objectType); + + return new GitPackDeltafiedStream(baseObjectStream, deltaStream); + } + else if (type == GitPackObjectType.OBJ_REF_DELTA) + { + Span baseObjectId = stackalloc byte[20]; + stream.ReadAll(baseObjectId); + + Stream baseObject = pack.GetObjectFromRepository(GitObjectId.Parse(baseObjectId), objectType)!; + var seekableBaseObject = new GitPackMemoryCacheStream(baseObject); + + var deltaStream = new ZLibStream(stream, decompressedSize); + + return new GitPackDeltafiedStream(seekableBaseObject, deltaStream); + } + + // Tips for handling deltas: https://github.com/choffmeister/gitnet/blob/4d907623d5ce2d79a8875aee82e718c12a8aad0b/src/GitNet/GitPack.cs + if (type != packObjectType) + { + throw new GitException($"An object of type {objectType} could not be located at offset {offset}.") { ErrorCode = GitException.ErrorCodes.ObjectNotFound }; + } + + return new ZLibStream(stream, decompressedSize); + } + + private static (GitPackObjectType, long) ReadObjectHeader(Stream stream) + { + Span value = stackalloc byte[1]; + stream.Read(value); + + var type = (GitPackObjectType)((value[0] & 0b0111_0000) >> 4); + long length = value[0] & 0b_1111; + + if ((value[0] & 0b1000_0000) == 0) + { + return (type, length); + } + + int shift = 4; + + do + { + stream.Read(value); + length = length | ((value[0] & (long)0b0111_1111) << shift); + shift += 7; + } while ((value[0] & 0b1000_0000) != 0); + + return (type, length); + } + + private static int ReadVariableLengthInteger(Stream stream) + { + int offset = -1; + int b; + + do + { + offset++; + b = stream.ReadByte(); + offset = (offset << 7) + (b & 127); + } + while ((b & (byte)128) != 0); + + return offset; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitReferenceReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitReferenceReader.cs new file mode 100644 index 00000000..530adefa --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitReferenceReader.cs @@ -0,0 +1,39 @@ +#nullable enable + +using System; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal class GitReferenceReader + { + private readonly static byte[] RefPrefix = GitRepository.Encoding.GetBytes("ref: "); + + public static object ReadReference(Stream stream) + { + if (stream.Length == 41) + { + Span objectId = stackalloc byte[40]; + stream.Read(objectId); + + return GitObjectId.ParseHex(objectId); + } + else + { + Span prefix = stackalloc byte[RefPrefix.Length]; + stream.Read(prefix); + + if (!prefix.SequenceEqual(RefPrefix)) + { + throw new GitException(); + } + + // Skip the terminating \n character + Span reference = stackalloc byte[(int)stream.Length - RefPrefix.Length - 1]; + stream.Read(reference); + + return GitRepository.GetString(reference); + } + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs b/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs new file mode 100644 index 00000000..1fee940d --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs @@ -0,0 +1,756 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Provides access to a Git repository. + /// + public class GitRepository : IDisposable + { + private const string HeadFileName = "HEAD"; + private const string GitDirectoryName = ".git"; + private readonly Lazy packs; + + /// + /// UTF-16 encoded string. + /// + private readonly char[] objectPathBuffer; + + private readonly List alternates = new List(); + +#if DEBUG && !NETSTANDARD + private Dictionary histogram = new Dictionary(); +#endif + + /// + /// Creates a new instance of the class. + /// + /// + /// The current working directory. This can be a subdirectory of the Git repository. + /// + /// + /// A which represents the git repository, or + /// if no git repository was found. + /// + public static GitRepository? Create(string? workingDirectory) + { + // Search for the top-level directory of the current git repository. This is the directory + // which contains a directory of file named .git. + // Loop until Path.GetDirectoryName returns null; in this case, we've reached the root of + // the file system (and we're not in a git repository). + while (!string.IsNullOrEmpty(workingDirectory) + && !File.Exists(Path.Combine(workingDirectory, GitDirectoryName)) + && !Directory.Exists(Path.Combine(workingDirectory, GitDirectoryName))) + { + workingDirectory = Path.GetDirectoryName(workingDirectory); + } + + if (string.IsNullOrEmpty(workingDirectory)) + { + return null; + } + + var gitDirectory = Path.Combine(workingDirectory, GitDirectoryName); + + if (File.Exists(gitDirectory)) + { + // This is a worktree, and the path to the git directory is stored in the .git file + var worktreeConfig = File.ReadAllText(gitDirectory); + + var gitDirStart = worktreeConfig.IndexOf("gitdir: "); + var gitDirEnd = worktreeConfig.IndexOf("\n", gitDirStart); + + gitDirectory = worktreeConfig.Substring(gitDirStart + 8, gitDirEnd - gitDirStart - 8); + } + + if (!Directory.Exists(gitDirectory)) + { + return null; + } + + var commonDirectory = gitDirectory; + var commonDirFile = Path.Combine(gitDirectory, "commondir"); + + if (File.Exists(commonDirFile)) + { + var commonDirectoryRelativePath = File.ReadAllText(commonDirFile).Trim('\n'); + commonDirectory = Path.Combine(gitDirectory, commonDirectoryRelativePath); + } + + var objectDirectory = Path.Combine(commonDirectory, "objects"); + + return new GitRepository(workingDirectory!, gitDirectory, commonDirectory, objectDirectory); + } + + /// + /// Creates a new instance of the class. + /// + /// + /// The current working directory. This can be a subdirectory of the Git repository. + /// + /// + /// The directory in which git metadata (such as refs,...) is stored. + /// + /// + /// The common Git directory, in which Git objects are stored. + /// + /// + /// The object directory in which Git objects are stored. + /// + public static GitRepository Create(string workingDirectory, string gitDirectory, string commonDirectory, string objectDirectory) + { + return new GitRepository(workingDirectory, gitDirectory, commonDirectory, objectDirectory); + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The current working directory. This can be a subdirectory of the Git repository. + /// + /// + /// The directory in which git metadata (such as refs,...) is stored. + /// + /// + /// The common Git directory, in which Git objects are stored. + /// + /// + /// The object directory in which Git objects are stored. + /// + public GitRepository(string workingDirectory, string gitDirectory, string commonDirectory, string objectDirectory) + { + this.WorkingDirectory = workingDirectory ?? throw new ArgumentNullException(nameof(workingDirectory)); + this.GitDirectory = gitDirectory ?? throw new ArgumentNullException(nameof(gitDirectory)); + this.CommonDirectory = commonDirectory ?? throw new ArgumentNullException(nameof(commonDirectory)); + this.ObjectDirectory = objectDirectory ?? throw new ArgumentNullException(nameof(objectDirectory)); + + // Normalize paths + this.WorkingDirectory = TrimEndingDirectorySeparator(Path.GetFullPath(this.WorkingDirectory)); + this.GitDirectory = TrimEndingDirectorySeparator(Path.GetFullPath(this.GitDirectory)); + this.CommonDirectory = TrimEndingDirectorySeparator(Path.GetFullPath(this.CommonDirectory)); + this.ObjectDirectory = TrimEndingDirectorySeparator(Path.GetFullPath(this.ObjectDirectory)); + + if (FileHelpers.TryOpen( + Path.Combine(this.ObjectDirectory, "info", "alternates"), + out var alternateStream)) + { + // There's not a lot of documentation on git alternates; but this StackOverflow question + // https://stackoverflow.com/questions/36123655/what-is-the-git-alternates-mechanism + // provides a good starting point. + Span alternates = stackalloc byte[4096]; + var length = alternateStream!.Read(alternates); + alternates = alternates.Slice(0, length); + + int index = 0; + + while ((index = alternates.IndexOf((byte)':')) > 0) + { + var alternate = GetString(alternates.Slice(0, index)); + alternate = Path.GetFullPath(Path.Combine(this.ObjectDirectory, alternate)); + + this.alternates.Add(GitRepository.Create(workingDirectory, gitDirectory, commonDirectory, alternate)); + + alternates = alternates.Slice(index + 1); + } + } + + + int pathLengthInChars = this.ObjectDirectory.Length + + 1 // '/' + + 2 // 'xy' is first byte as 2 hex characters. + + 1 // '/' + + 38 // 19 bytes * 2 hex chars each + + 1; // Trailing null character + this.objectPathBuffer = new char[pathLengthInChars]; + this.ObjectDirectory.CopyTo(0, this.objectPathBuffer, 0, this.ObjectDirectory.Length); + + this.objectPathBuffer[this.ObjectDirectory.Length] = '/'; + this.objectPathBuffer[this.ObjectDirectory.Length + 3] = '/'; + this.objectPathBuffer[pathLengthInChars - 1] = '\0'; // Make sure to initialize with zeros + + this.packs = new Lazy(this.LoadPacks); + } + + // TODO: read from Git settings + /// + /// Gets a value indicating whether this Git repository is case-insensitive. + /// + public bool IgnoreCase { get; private set; } = false; + + /// + /// Gets the path to the current working directory. + /// + public string WorkingDirectory { get; private set; } + + /// + /// Gets the path to the Git directory, in which metadata (e.g. references and configuration) is stored. + /// + public string GitDirectory { get; private set; } + + /// + /// Gets the path to the common directory, in which shared Git data (e.g. objects) are stored. + /// + public string CommonDirectory { get; private set; } + + /// + /// Gets the path to the Git object directory. It is a subdirectory of . + /// + public string ObjectDirectory { get; private set; } + + /// + /// Gets the encoding used by this Git repository. + /// + public static Encoding Encoding => Encoding.UTF8; + + /// + /// Shortens the object id + /// + /// + /// The object Id to shorten. + /// + /// + /// The minimum string length. + /// + /// + /// The short object id. + /// + public string ShortenObjectId(GitObjectId objectId, int minimum) + { + var sha = objectId.ToString(); + + for (int length = minimum; length < sha.Length; length += 2) + { + var objectish = sha.Substring(0, length); + + if (this.Lookup(objectish) != null) + { + return objectish; + } + } + + return sha; + } + + /// + /// Returns the current HEAD as a reference (if available) or a Git object id. + /// + /// + /// The current HEAD as a reference (if available) or a Git object id. + /// + public object GetHeadAsReferenceOrSha() + { + using (var stream = File.OpenRead(Path.Combine(this.GitDirectory, HeadFileName))) + { + return GitReferenceReader.ReadReference(stream); + } + } + + /// + /// Gets the object ID of the current HEAD. + /// + /// + /// The object ID of the current HEAD. + /// + public GitObjectId GetHeadCommitSha() + { + var reference = this.GetHeadAsReferenceOrSha(); + var objectId = this.ResolveReference(reference); + return objectId; + } + + /// + /// Gets the current HEAD commit, if available. + /// + /// + /// A value indicating whether to populate the field. + /// + /// + /// The current HEAD commit, or if not available. + /// + public GitCommit? GetHeadCommit(bool readAuthor = false) + { + var headCommitId = this.GetHeadCommitSha(); + + if (headCommitId == GitObjectId.Empty) + { + return null; + } + + return this.GetCommit(headCommitId, readAuthor); + } + + /// + /// Gets a commit by its Git object Id. + /// + /// + /// The Git object Id of the commit. + /// + /// + /// A value indicating whether to populate the field. + /// + /// + /// The requested commit. + /// + public GitCommit GetCommit(GitObjectId sha, bool readAuthor = false) + { + using (Stream? stream = this.GetObjectBySha(sha, "commit")) + { + if (stream == null) + { + throw new GitException($"The commit {sha} was not found in this repository.") { ErrorCode = GitException.ErrorCodes.ObjectNotFound }; + } + + return GitCommitReader.Read(stream, sha, readAuthor); + } + } + + /// + /// Parses any committish to an object id. + /// + /// Any "objectish" string (e.g. commit ID (partial or full), branch name, tag name, or "HEAD"). + /// The object ID referenced by if found; otherwise . + public GitObjectId? Lookup(string objectish) + { + if (objectish == "HEAD") + { + return this.GetHeadCommitSha(); + } + + var possibleLooseFileMatches = new List(); + if (objectish.StartsWith("refs/", StringComparison.Ordinal)) + { + // Match on loose ref files by their canonical name. + possibleLooseFileMatches.Add(Path.Combine(this.GitDirectory, objectish)); + } + else + { + // Look for simple names for branch or tag. + possibleLooseFileMatches.Add(Path.Combine(this.GitDirectory, "refs", "heads", objectish)); + possibleLooseFileMatches.Add(Path.Combine(this.GitDirectory, "refs", "tags", objectish)); + possibleLooseFileMatches.Add(Path.Combine(this.GitDirectory, "refs", "remotes", objectish)); + } + + if (possibleLooseFileMatches.FirstOrDefault(File.Exists) is string existingPath) + { + return GitObjectId.Parse(File.ReadAllText(existingPath).TrimEnd()); + } + + // Match in packed-refs file. + string packedRefPath = Path.Combine(this.GitDirectory, "packed-refs"); + if (File.Exists(packedRefPath)) + { + using var refReader = File.OpenText(packedRefPath); + string? line; + while ((line = refReader.ReadLine()) is object) + { + if (line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + string refName = line.Substring(41); + if (string.Equals(refName, objectish, StringComparison.Ordinal)) + { + return GitObjectId.Parse(line.Substring(0, 40)); + } + else if (!objectish.StartsWith("refs/", StringComparison.Ordinal)) + { + // Not a canonical ref, so try heads and tags + if (string.Equals(refName, "refs/heads/" + objectish, StringComparison.Ordinal)) + { + return GitObjectId.Parse(line.Substring(0, 40)); + } + else if (string.Equals(refName, "refs/tags/" + objectish, StringComparison.Ordinal)) + { + return GitObjectId.Parse(line.Substring(0, 40)); + } + else if (string.Equals(refName, "refs/remotes/" + objectish, StringComparison.Ordinal)) + { + return GitObjectId.Parse(line.Substring(0, 40)); + } + } + } + } + + if (objectish.Length == 40) + { + return GitObjectId.Parse(objectish); + } + + var possibleObjectIds = new List(); + if (objectish.Length > 2 && objectish.Length < 40) + { + // Search for _any_ object whose id starts with objectish in the object database + var directory = Path.Combine(this.ObjectDirectory, objectish.Substring(0, 2)); + + if (Directory.Exists(directory)) + { + var files = Directory.GetFiles(directory, $"{objectish.Substring(2)}*"); + + foreach (var file in files) + { + var objectId = $"{objectish.Substring(0, 2)}{Path.GetFileName(file)}"; + possibleObjectIds.Add(GitObjectId.Parse(objectId)); + } + } + + // Search for _any_ object whose id starts with objectish in the packfile + bool endsWithHalfByte = objectish.Length % 2 == 1; + if (endsWithHalfByte) + { + // Add one more character so hex can be converted to bytes. + // The bit length to be compared will not consider the last four bits. + objectish += "0"; + } + + var hex = ConvertHexStringToByteArray(objectish); + + foreach (var pack in this.packs.Value) + { + var objectId = pack.Lookup(hex, endsWithHalfByte); + + // It's possible for the same object to be present in both the object database and the pack files, + // or in multiple pack files. + if (objectId != null && !possibleObjectIds.Contains(objectId.Value)) + { + if (possibleObjectIds.Count > 0) + { + // If objectish already resolved to at least one object which is different from the current + // object id, objectish is not well-defined; so stop resolving and return null instead. + return null; + } + else + { + possibleObjectIds.Add(objectId.Value); + } + } + } + } + + if (possibleObjectIds.Count == 1) + { + return possibleObjectIds[0]; + } + + return null; + } + + /// + /// Gets a tree object by its Git object Id. + /// + /// + /// The Git object Id of the tree. + /// + /// + /// The requested tree. + /// + public GitTree GetTree(GitObjectId sha) + { + using (Stream? stream = this.GetObjectBySha(sha, "tree")) + { + if (stream == null) + { + throw new GitException($"The tree {sha} was not found in this repository.") { ErrorCode = GitException.ErrorCodes.ObjectNotFound }; + } + + return GitTreeReader.Read(stream, sha); + } + } + + /// + /// Gets an entry in a git tree. + /// + /// + /// The Git object Id of the Git tree. + /// + /// + /// The name of the node in the Git tree. + /// + /// + /// The object Id of the requested entry. Returns if the entry + /// could not be found. + /// + public GitObjectId GetTreeEntry(GitObjectId treeId, ReadOnlySpan nodeName) + { + using (Stream? treeStream = this.GetObjectBySha(treeId, "tree")) + { + if (treeStream == null) + { + throw new GitException($"The tree {treeId} was not found in this repository.") { ErrorCode = GitException.ErrorCodes.ObjectNotFound }; + } + + return GitTreeStreamingReader.FindNode(treeStream, nodeName); + } + } + + /// + /// Gets a Git object by its Git object Id. + /// + /// + /// The Git object id of the object to retrieve. + /// + /// + /// The type of object to retrieve. + /// + /// + /// A which represents the requested object. + /// + /// + /// The requested object could not be found. + /// + /// + /// As a special case, a value will be returned for + /// . + /// + public Stream? GetObjectBySha(GitObjectId sha, string objectType) + { + if (sha == GitObjectId.Empty) + { + return null; + } + + if (this.TryGetObjectBySha(sha, objectType, out Stream? value)) + { + return value; + } + else + { + throw new GitException($"An {objectType} object with SHA {sha} could not be found.") { ErrorCode = GitException.ErrorCodes.ObjectNotFound }; + } + } + + /// + /// Gets a Git object by its Git object Id. + /// + /// + /// The Git object id of the object to retrieve. + /// + /// + /// The type of object to retrieve. + /// + /// + /// An output parameter which retrieves the requested Git object. + /// + /// + /// if the object could be found; otherwise, + /// . + /// + public bool TryGetObjectBySha(GitObjectId sha, string objectType, out Stream? value) + { +#if DEBUG && !NETSTANDARD + if (!this.histogram.TryAdd(sha, 1)) + { + this.histogram[sha] += 1; + } +#endif + + foreach (var pack in this.packs.Value) + { + if (pack.TryGetObject(sha, objectType, out value)) + { + return true; + } + } + + if (this.TryGetObjectByPath(sha, objectType, out value)) + { + return true; + } + + foreach (var alternate in this.alternates) + { + if (alternate.TryGetObjectBySha(sha, objectType, out value)) + { + return true; + } + } + + value = null; + return false; + } + + /// + /// Gets cache usage statistics. + /// + /// + /// A which represents the cache usage statistics. + /// + public string GetCacheStatistics() + { + StringBuilder builder = new StringBuilder(); + +#if DEBUG && !NETSTANDARD + int histogramCount = 25; + + builder.AppendLine("Overall repository:"); + builder.AppendLine($"Top {histogramCount} / {this.histogram.Count} items:"); + + foreach (var item in this.histogram.OrderByDescending(v => v.Value).Take(25)) + { + builder.AppendLine($" {item.Key}: {item.Value}"); + } + + builder.AppendLine(); +#endif + + foreach (var pack in this.packs.Value) + { + pack.GetCacheStatistics(builder); + } + + return builder.ToString(); + } + + /// + public override string ToString() + { + return $"Git Repository: {this.WorkingDirectory}"; + } + + /// + public void Dispose() + { + if (this.packs.IsValueCreated) + { + foreach (var pack in this.packs.Value) + { + pack.Dispose(); + } + } + } + + private bool TryGetObjectByPath(GitObjectId sha, string objectType, [NotNullWhen(true)] out Stream? value) + { + sha.CopyAsHex(0, 1, this.objectPathBuffer.AsSpan(this.ObjectDirectory.Length + 1, 2)); + sha.CopyAsHex(1, 19, this.objectPathBuffer.AsSpan(this.ObjectDirectory.Length + 1 + 2 + 1)); + + if (!FileHelpers.TryOpen(this.objectPathBuffer, out var compressedFile)) + { + value = null; + return false; + } + + var objectStream = new GitObjectStream(compressedFile!, objectType); + + if (string.CompareOrdinal(objectStream.ObjectType, objectType) != 0) + { + throw new GitException($"Got a {objectStream.ObjectType} instead of a {objectType} when opening object {sha}"); + } + + value = objectStream; + return true; + } + + private GitObjectId ResolveReference(object reference) + { + if (reference is string) + { + if (!FileHelpers.TryOpen(Path.Combine(this.GitDirectory, (string)reference), out FileStream? stream)) + { + return GitObjectId.Empty; + } + + using (stream) + { + Span objectId = stackalloc byte[40]; + stream!.Read(objectId); + + return GitObjectId.ParseHex(objectId); + } + } + else if (reference is GitObjectId) + { + return (GitObjectId)reference; + } + else + { + throw new GitException(); + } + } + + private GitPack[] LoadPacks() + { + var packDirectory = Path.Combine(this.ObjectDirectory, "pack/"); + + if (!Directory.Exists(packDirectory)) + { + return Array.Empty(); + } + + var indexFiles = Directory.GetFiles(packDirectory, "*.idx"); + GitPack[] packs = new GitPack[indexFiles.Length]; + + for (int i = 0; i < indexFiles.Length; i++) + { + var name = Path.GetFileNameWithoutExtension(indexFiles[i]); + packs[i] = new GitPack(this, name); + } + + return packs; + } + + private static string TrimEndingDirectorySeparator(string path) + { +#if NETSTANDARD + if (string.IsNullOrEmpty(path) || path.Length == 1) + { + return path; + } + + var last = path[path.Length - 1]; + + if (last == Path.DirectorySeparatorChar || last == Path.AltDirectorySeparatorChar) + { + return path.Substring(0, path.Length - 1); + } + + return path; +#else + return Path.TrimEndingDirectorySeparator(path); +#endif + } + + private static byte[] ConvertHexStringToByteArray(string hexString) + { + // https://stackoverflow.com/questions/321370/how-can-i-convert-a-hex-string-to-a-byte-array + if (hexString.Length % 2 != 0) + { + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "The binary key cannot have an odd number of digits: {0}", hexString)); + } + + byte[] data = new byte[hexString.Length / 2]; + for (int index = 0; index < data.Length; index++) + { + string byteValue = hexString.Substring(index * 2, 2); + data[index] = byte.Parse(byteValue, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + return data; + } + + /// + /// Decodes a sequence of bytes from the specified byte array into a . + /// + /// + /// The span containing the sequence of UTF-8 bytes to decode. + /// + /// + /// A that contains the results of decoding the specified sequence of bytes. + /// + public static unsafe string GetString(ReadOnlySpan bytes) + { + fixed (byte* pBytes = bytes) + { + return Encoding.GetString(pBytes, bytes.Length); + } + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitSignature.cs b/src/NerdBank.GitVersioning/ManagedGit/GitSignature.cs new file mode 100644 index 00000000..53d56759 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitSignature.cs @@ -0,0 +1,27 @@ +#nullable enable + +using System; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Represents the signature of a Git committer or author. + /// + public struct GitSignature + { + /// + /// Gets or sets the name of the committer or author. + /// + public string Name { get; set; } + + /// + /// Gets or sets the e-mail address of the commiter or author. + /// + public string Email { get; set; } + + /// + /// Gets or sets the date and time at which the commit was made. + /// + public DateTimeOffset Date { get; set; } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitTree.cs b/src/NerdBank.GitVersioning/ManagedGit/GitTree.cs new file mode 100644 index 00000000..6635d630 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitTree.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Represents a git tree. + /// + public class GitTree + { + /// + /// Gets an empty . + /// + public static GitTree Empty { get; } = new GitTree(); + + /// + /// The Git object Id of this . + /// + public GitObjectId Sha { get; set; } + + /// + /// Gets a dictionary which contains all entries in the current tree, accessible by name. + /// + public Dictionary Children { get; } = new Dictionary(); + + /// + public override string ToString() + { + return $"Git tree: {this.Sha}"; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitTreeEntry.cs b/src/NerdBank.GitVersioning/ManagedGit/GitTreeEntry.cs new file mode 100644 index 00000000..a213b268 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitTreeEntry.cs @@ -0,0 +1,50 @@ +#nullable enable + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Represents an individual entry in the Git tree. + /// + public class GitTreeEntry + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The name of the entry. + /// + /// + /// A vaolue indicating whether the current entry is a file. + /// + /// + /// The Git object Id of the blob or tree of the current entry. + /// + public GitTreeEntry(string name, bool isFile, GitObjectId sha) + { + this.Name = name; + this.IsFile = isFile; + this.Sha = sha; + } + + /// + /// Gets the name of the entry. + /// + public string Name { get; } + + /// + /// Gets a value indicating whether the current entry is a file. + /// + public bool IsFile { get; } + + /// + /// Gets the Git object Id of the blob or tree of the current entry. + /// + public GitObjectId Sha { get; } + + /// + public override string ToString() + { + return this.Name; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitTreeReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitTreeReader.cs new file mode 100644 index 00000000..7baf8451 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitTreeReader.cs @@ -0,0 +1,57 @@ +#nullable enable + +using System; +using System.Buffers; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + internal static class GitTreeReader + { + public static GitTree Read(Stream stream, GitObjectId objectId) + { + byte[] buffer = ArrayPool.Shared.Rent((int)stream.Length); +#if DEBUG + Array.Clear(buffer, 0, buffer.Length); +#endif + + GitTree value = new GitTree() + { + Sha = objectId, + }; + + try + { + Span contents = buffer.AsSpan(0, (int)stream.Length); + stream.ReadAll(contents); + + while (contents.Length > 0) + { + // Format: [mode] [file/ folder name]\0[SHA - 1 of referencing blob or tree] + // Mode is either 6-bytes long (directory) or 7-bytes long (file). + // If the entry is a file, the first byte is '1' + var fileNameEnds = contents.IndexOf((byte)0); + bool isFile = contents[0] == (byte)'1'; + var modeLength = isFile ? 7 : 6; + + var currentName = contents.Slice(modeLength, fileNameEnds - modeLength); + var currentObjectId = GitObjectId.Parse(contents.Slice(fileNameEnds + 1, 20)); + + var name = GitRepository.GetString(currentName); + + value.Children.Add( + name, + new GitTreeEntry(name, isFile, currentObjectId)); + + contents = contents.Slice(fileNameEnds + 1 + 20); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return value; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/GitTreeStreamingReader.cs b/src/NerdBank.GitVersioning/ManagedGit/GitTreeStreamingReader.cs new file mode 100644 index 00000000..fe0db93a --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/GitTreeStreamingReader.cs @@ -0,0 +1,62 @@ +#nullable enable + +using System; +using System.Buffers; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Reads git tree objects. + /// + public class GitTreeStreamingReader + { + /// + /// Finds a specific node in a git tree. + /// + /// + /// A which represents the git tree. + /// + /// + /// The name of the node to find, in it UTF-8 representation. + /// + /// + /// The of the requested node. + /// + public static GitObjectId FindNode(Stream stream, ReadOnlySpan name) + { + byte[] buffer = ArrayPool.Shared.Rent((int)stream.Length); + Span contents = new Span(buffer, 0, (int)stream.Length); + + stream.ReadAll(contents); + + GitObjectId value = GitObjectId.Empty; + + while (contents.Length > 0) + { + // Format: [mode] [file/ folder name]\0[SHA - 1 of referencing blob or tree] + // Mode is either 6-bytes long (directory) or 7-bytes long (file). + // If the entry is a file, the first byte is '1' + var fileNameEnds = contents.IndexOf((byte)0); + bool isFile = contents[0] == (byte)'1'; + var modeLength = isFile ? 7 : 6; + + var currentName = contents.Slice(modeLength, fileNameEnds - modeLength); + + if (currentName.SequenceEqual(name)) + { + value = GitObjectId.Parse(contents.Slice(fileNameEnds + 1, 20)); + break; + } + else + { + contents = contents.Slice(fileNameEnds + 1 + 20); + } + } + + ArrayPool.Shared.Return(buffer); + + return value; + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/MemoryMappedStream.cs b/src/NerdBank.GitVersioning/ManagedGit/MemoryMappedStream.cs new file mode 100644 index 00000000..207c5a29 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/MemoryMappedStream.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Provides read-only, seekable access to a . + /// + public unsafe class MemoryMappedStream : Stream + { + private readonly MemoryMappedViewAccessor accessor; + private readonly long length; + private long position; + private byte* ptr; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The accessor to the memory mapped stream. + /// + public MemoryMappedStream(MemoryMappedViewAccessor accessor) + { + this.accessor = accessor ?? throw new ArgumentNullException(nameof(accessor)); + this.accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref this.ptr); + this.length = this.accessor.Capacity; + } + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + public override long Length => this.length; + + /// + public override long Position + { + get => this.position; + set + { + this.position = (int)value; + } + } + + /// + public override void Flush() + { + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + int read = (int)Math.Min(count, this.length - this.position); + + new Span(this.ptr + this.position, read) + .CopyTo(buffer.AsSpan(offset, count)); + + this.position += read; + return read; + } + +#if !NETSTANDARD + /// + public override int Read(Span buffer) + { + int read = (int)Math.Min(buffer.Length, this.length - this.position); + + new Span(this.ptr + this.position, read) + .CopyTo(buffer); + + this.position += read; + return read; + } +#endif + + /// + public override long Seek(long offset, SeekOrigin origin) + { + long newPosition = this.position; + + switch (origin) + { + case SeekOrigin.Begin: + newPosition = offset; + break; + + case SeekOrigin.Current: + newPosition += offset; + break; + + case SeekOrigin.End: + throw new NotSupportedException(); + } + + if (newPosition > this.length) + { + newPosition = this.length; + } + + if (newPosition < 0) + { + throw new IOException("Attempted to seek before the start or beyond the end of the stream."); + } + + this.position = newPosition; + return this.position; + } + + /// + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/StreamExtensions.cs b/src/NerdBank.GitVersioning/ManagedGit/StreamExtensions.cs new file mode 100644 index 00000000..98d382fa --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/StreamExtensions.cs @@ -0,0 +1,144 @@ +#nullable enable + +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// Provides extension methods for the class. + /// + public static class StreamExtensions + { + /// + /// Reads data from a to fill a given buffer. + /// + /// + /// The from which to read data. + /// + /// + /// A buffer into which to store the data read. + /// + /// Thrown when the stream runs out of data before could be filled. + public static void ReadAll(this Stream stream, Span buffer) + { + if (buffer.Length == 0) + { + return; + } + + int totalBytesRead = 0; + while (totalBytesRead < buffer.Length) + { + int bytesRead = stream.Read(buffer.Slice(totalBytesRead)); + if (bytesRead == 0) + { + throw new EndOfStreamException(); + } + + totalBytesRead += bytesRead; + } + } + + /// + /// Reads an variable-length integer off a . + /// + /// + /// The stream off which to read the variable-length integer. + /// + /// + /// The requested value. + /// + /// Thrown when the stream runs out of data before the integer could be read. + public static int ReadMbsInt(this Stream stream) + { + int value = 0; + int currentBit = 0; + int read; + + while (true) + { + read = stream.ReadByte(); + if (read == -1) + { + throw new EndOfStreamException(); + } + + value |= (read & 0b_0111_1111) << currentBit; + currentBit += 7; + + if (read < 128) + { + break; + } + } + + return value; + } + +#if NETSTANDARD + /// + /// Reads a sequence of bytes from the current stream and advances the position within the stream by + /// the number of bytes read. + /// + /// + /// The from which to read the data. + /// + /// + /// A region of memory. When this method returns, the contents of this region are replaced by the bytes + /// read from the current source. + /// + /// + /// The total number of bytes read into the buffer. This can be less than the number of bytes allocated + /// in the buffer if that many bytes are not currently available, or zero (0) if the end of the stream + /// has been reached. + /// + public static int Read(this Stream stream, Span span) + { + byte[]? buffer = null; + + try + { + buffer = ArrayPool.Shared.Rent(span.Length); + int read = stream.Read(buffer, 0, span.Length); + + buffer.AsSpan(0, read).CopyTo(span); + return read; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Writes a sequence of bytes to the current stream and advances the current position within this stream + /// by the number of bytes written. + /// + /// + /// The to which to write the data. + /// + /// + /// A region of memory. This method copies the contents of this region to the current stream. + /// + public static void Write(this Stream stream, Span span) + { + byte[]? buffer = null; + + try + { + buffer = ArrayPool.Shared.Rent(span.Length); + span.CopyTo(buffer.AsSpan(0, span.Length)); + + stream.Write(buffer, 0, span.Length); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +#endif + } +} diff --git a/src/NerdBank.GitVersioning/ManagedGit/ZLibStream.cs b/src/NerdBank.GitVersioning/ManagedGit/ZLibStream.cs new file mode 100644 index 00000000..31d11366 --- /dev/null +++ b/src/NerdBank.GitVersioning/ManagedGit/ZLibStream.cs @@ -0,0 +1,203 @@ + +#nullable enable + +using System; +using System.Buffers; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +namespace Nerdbank.GitVersioning.ManagedGit +{ + /// + /// A which reads zlib-compressed data. + /// + /// + /// + /// This stream parses but ignores the two-byte zlib header at the start of the compressed + /// stream. + /// + /// + /// This stream keeps track of the current position and, if provided via the constructor, + /// the length. + /// + /// + /// This class wraps a rather than inheriting from it, because + /// detects whether Read(Span{byte}) is being overriden + /// and behaves differently when it is. + /// + /// + /// .NET 5.0 ships with a built-in ZLibStream; which may render (parts of) this implementation + /// obsolete. + /// + /// + /// + public class ZLibStream : Stream + { + private long length; + private long position; + private DeflateStream stream; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The from which to read data. + /// + /// + /// The size of the uncompressed data. + /// + public ZLibStream(Stream stream, long length = -1) + { + this.stream = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: false); + this.length = length; + + Span zlibHeader = stackalloc byte[2]; + stream.ReadAll(zlibHeader); + + if (zlibHeader[0] != 0x78 || (zlibHeader[1] != 0x01 && zlibHeader[1] != 0x9C)) + { + throw new GitException(); + } + } + + /// + /// Gets the from which the data is being read. + /// + public Stream BaseStream => this.stream; + + /// + public override long Position + { + get => this.position; + set => throw new NotSupportedException(); + } + + /// + public override long Length => this.length; + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + public override int Read(byte[] array, int offset, int count) + { + int read = this.stream.Read(array, offset, count); + this.position += read; + return read; + } + +#if !NETSTANDARD + /// + public override int Read(Span buffer) + { + int read = this.stream.Read(buffer); + this.position += read; + return read; + } +#endif + + /// + public override async Task ReadAsync(byte[] array, int offset, int count, CancellationToken cancellationToken) + { + int read = await this.stream.ReadAsync(array, offset, count, cancellationToken); + this.position += read; + return read; + } + +#if !NETSTANDARD + /// + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int read = await this.stream.ReadAsync(buffer, cancellationToken); + this.position += read; + return read; + } +#endif + + /// + public override int ReadByte() + { + int value = this.stream.ReadByte(); + + if (value != -1) + { + this.position += 1; + } + + return value; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + if (origin == SeekOrigin.Begin && offset == this.position) + { + return this.position; + } + + if (origin == SeekOrigin.Current && offset == 0) + { + return this.position; + } + + if (origin == SeekOrigin.Begin && offset > this.position) + { + // We may be able to optimize this by skipping over the compressed data + int length = (int)(offset - this.position); + + byte[] buffer = ArrayPool.Shared.Rent(length); + this.Read(buffer, 0, length); + ArrayPool.Shared.Return(buffer); + return this.position; + } + else + { + throw new NotImplementedException(); + } + } + + /// + public override void Flush() + { + throw new NotSupportedException(); + } + + /// + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + /// + protected override void Dispose(bool disposing) + { + this.stream.Dispose(); + } + + /// + /// Initializes the length and position properties. + /// + /// + /// The length of this class. + /// + protected void Initialize(long length) + { + this.position = 0; + this.length = length; + } + } +} diff --git a/src/NerdBank.GitVersioning/NerdBank.GitVersioning.csproj b/src/NerdBank.GitVersioning/NerdBank.GitVersioning.csproj index 27c8bf93..a71c536d 100644 --- a/src/NerdBank.GitVersioning/NerdBank.GitVersioning.csproj +++ b/src/NerdBank.GitVersioning/NerdBank.GitVersioning.csproj @@ -1,19 +1,23 @@  - netstandard2.0 + netstandard2.0;netcoreapp3.1 true Full false Nerdbank.GitVersioning.Core + true + Nerdbank.GitVersioning - - - + + + + + diff --git a/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs b/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs new file mode 100644 index 00000000..4d8324db --- /dev/null +++ b/src/NerdBank.GitVersioning/NoGit/NoGitContext.cs @@ -0,0 +1,38 @@ +#nullable enable + +using System; +using System.Diagnostics; + +namespace Nerdbank.GitVersioning +{ + [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] + internal class NoGitContext : GitContext + { + private const string NotAGitRepoMessage = "Not a git repo"; + + public NoGitContext(string workingTreePath) + : base(workingTreePath, null) + { + this.VersionFile = new NoGitVersionFile(this); + } + + public override VersionFile VersionFile { get; } + + public override string? GitCommitId => null; + + public override bool IsHead => false; + + public override DateTimeOffset? GitCommitDate => null; + + public override string? HeadCanonicalName => null; + + private string DebuggerDisplay => $"\"{this.WorkingTreePath}\" (no-git)"; + + public override void ApplyTag(string name) => throw new InvalidOperationException(NotAGitRepoMessage); + public override void Stage(string path) => throw new InvalidOperationException(NotAGitRepoMessage); + public override string GetShortUniqueCommitId(int minLength) => throw new InvalidOperationException(NotAGitRepoMessage); + public override bool TrySelectCommit(string committish) => throw new InvalidOperationException(NotAGitRepoMessage); + internal override int CalculateVersionHeight(VersionOptions? committedVersion, VersionOptions? workingVersion) => 0; + internal override Version GetIdAsVersion(VersionOptions? committedVersion, VersionOptions? workingVersion, int versionHeight) => throw new NotImplementedException(); + } +} diff --git a/src/NerdBank.GitVersioning/NoGit/NoGitVersionFile.cs b/src/NerdBank.GitVersioning/NoGit/NoGitVersionFile.cs new file mode 100644 index 00000000..44229691 --- /dev/null +++ b/src/NerdBank.GitVersioning/NoGit/NoGitVersionFile.cs @@ -0,0 +1,14 @@ +namespace Nerdbank.GitVersioning +{ + using Validation; + + internal class NoGitVersionFile : VersionFile + { + public NoGitVersionFile(GitContext context) + : base(context) + { + } + + protected override VersionOptions GetVersionCore(out string actualDirectory) => throw Assumes.NotReachable(); + } +} diff --git a/src/NerdBank.GitVersioning/ReleaseManager.cs b/src/NerdBank.GitVersioning/ReleaseManager.cs index 8c02bee1..e30efa6a 100644 --- a/src/NerdBank.GitVersioning/ReleaseManager.cs +++ b/src/NerdBank.GitVersioning/ReleaseManager.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using LibGit2Sharp; + using Nerdbank.GitVersioning.LibGit2; using Newtonsoft.Json; using Validation; using Version = System.Version; @@ -11,6 +12,9 @@ /// /// Methods for creating releases /// + /// + /// This class authors git commits, branches, etc. and thus must use libgit2 rather than our internal managed implementation which is read-only. + /// public class ReleaseManager { /// @@ -79,6 +83,14 @@ public class ReleasePreparationException : Exception /// /// The error that occurred. public ReleasePreparationException(ReleasePreparationError error) => this.Error = error; + + /// + /// Initializes a new instance of + /// + /// The error that occurred. + /// The inner exception. + public ReleasePreparationException(ReleasePreparationError error, Exception innerException) + : base(null, innerException) => this.Error = error; } /// @@ -220,7 +232,8 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = Requires.NotNull(projectDirectory, nameof(projectDirectory)); // open the git repository - var repository = this.GetRepository(projectDirectory); + var context = this.GetRepository(projectDirectory); + var repository = context.Repository; if (repository.Info.IsHeadDetached) { @@ -229,7 +242,7 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = } // get the current version - var versionOptions = VersionFile.GetVersion(projectDirectory); + var versionOptions = context.VersionFile.GetVersion(); if (versionOptions == null) { this.stderr.WriteLine($"Failed to load version file for directory '{projectDirectory}'."); @@ -254,7 +267,7 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = var releaseInfo = new ReleaseInfo(new ReleaseBranchInfo(releaseBranchName, repository.Head.Tip.Id.ToString(), releaseVersion)); this.WriteToOutput(releaseInfo); } - this.UpdateVersion(projectDirectory, repository, versionOptions.Version, releaseVersion); + this.UpdateVersion(context, versionOptions.Version, releaseVersion); return; } @@ -270,7 +283,7 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = // create release branch and update version var releaseBranch = repository.CreateBranch(releaseBranchName); Commands.Checkout(repository, releaseBranch); - this.UpdateVersion(projectDirectory, repository, versionOptions.Version, releaseVersion); + this.UpdateVersion(context, versionOptions.Version, releaseVersion); if (outputMode == ReleaseManagerOutputMode.Text) { @@ -279,7 +292,7 @@ public void PrepareRelease(string projectDirectory, string releaseUnstableTag = // update version on main branch Commands.Checkout(repository, originalBranchName); - this.UpdateVersion(projectDirectory, repository, versionOptions.Version, nextDevVersion); + this.UpdateVersion(context, versionOptions.Version, nextDevVersion); if (outputMode == ReleaseManagerOutputMode.Text) { @@ -321,13 +334,12 @@ private string GetReleaseBranchName(VersionOptions versionOptions) return branchNameFormat.Replace("{version}", versionOptions.Version.Version.ToString()); } - private void UpdateVersion(string projectDirectory, Repository repository, SemanticVersion oldVersion, SemanticVersion newVersion) + private void UpdateVersion(LibGit2Context context, SemanticVersion oldVersion, SemanticVersion newVersion) { - Requires.NotNull(projectDirectory, nameof(projectDirectory)); - Requires.NotNull(repository, nameof(repository)); + Requires.NotNull(context, nameof(context)); - var signature = this.GetSignature(repository); - var versionOptions = VersionFile.GetVersion(repository, projectDirectory); + var signature = this.GetSignature(context.Repository); + var versionOptions = context.VersionFile.GetVersion(); if (IsVersionDecrement(oldVersion, newVersion)) { @@ -337,21 +349,21 @@ private void UpdateVersion(string projectDirectory, Repository repository, Seman if (!EqualityComparer.Default.Equals(versionOptions.Version, newVersion)) { - if (versionOptions.VersionHeightPosition.HasValue && GitExtensions.WillVersionChangeResetVersionHeight(versionOptions.Version, newVersion, versionOptions.VersionHeightPosition.Value)) + if (versionOptions.VersionHeightPosition.HasValue && SemanticVersion.WillVersionChangeResetVersionHeight(versionOptions.Version, newVersion, versionOptions.VersionHeightPosition.Value)) { // The version will be reset by this change, so remove the version height offset property. versionOptions.VersionHeightOffset = null; } versionOptions.Version = newVersion; - var filePath = VersionFile.SetVersion(projectDirectory, versionOptions, includeSchemaProperty: true); + var filePath = context.VersionFile.SetVersion(context.AbsoluteProjectDirectory, versionOptions, includeSchemaProperty: true); - Commands.Stage(repository, filePath); + Commands.Stage(context.Repository, filePath); // Author a commit only if we effectively changed something. - if (!repository.Head.Tip.Tree.Equals(repository.Index.WriteToTree())) + if (!context.Repository.Head.Tip.Tree.Equals(context.Repository.Index.WriteToTree())) { - repository.Commit($"Set version to '{versionOptions.Version}'", signature, signature, new CommitOptions() { AllowEmptyCommit = false }); + context.Repository.Commit($"Set version to '{versionOptions.Version}'", signature, signature, new CommitOptions() { AllowEmptyCommit = false }); } } } @@ -368,28 +380,31 @@ private Signature GetSignature(Repository repository) return signature; } - private Repository GetRepository(string projectDirectory) + private LibGit2Context GetRepository(string projectDirectory) { // open git repo and use default configuration (in order to commit we need a configured user name and email - // which is most likely configured on a user/system level rather than the repo level - var repository = GitExtensions.OpenGitRepo(projectDirectory, useDefaultConfigSearchPaths: true); - if (repository == null) + // which is most likely configured on a user/system level rather than the repo level. + var context = GitContext.Create(projectDirectory, writable: true); + if (!context.IsRepository) { this.stderr.WriteLine($"No git repository found above directory '{projectDirectory}'."); throw new ReleasePreparationException(ReleasePreparationError.NoGitRepo); } + var libgit2context = (LibGit2Context)context; + + // abort if there are any pending changes - if (repository.RetrieveStatus().IsDirty) + if (libgit2context.Repository.RetrieveStatus().IsDirty) { this.stderr.WriteLine($"Uncommitted changes in directory '{projectDirectory}'."); throw new ReleasePreparationException(ReleasePreparationError.UncommittedChanges); } // check if repo is configured so we can create commits - _ = this.GetSignature(repository); + _ = this.GetSignature(libgit2context.Repository); - return repository; + return libgit2context; } private static bool IsVersionDecrement(SemanticVersion oldVersion, SemanticVersion newVersion) diff --git a/src/NerdBank.GitVersioning/SemanticVersion.cs b/src/NerdBank.GitVersioning/SemanticVersion.cs index e0cccc34..9b503b46 100644 --- a/src/NerdBank.GitVersioning/SemanticVersion.cs +++ b/src/NerdBank.GitVersioning/SemanticVersion.cs @@ -126,6 +126,32 @@ internal enum Position /// A string with a leading plus or the empty string. public string BuildMetadata { get; } + /// + /// Gets the position in a computed version that the version height should appear. + /// + internal SemanticVersion.Position? VersionHeightPosition + { + get + { + if (this.Prerelease?.Contains(VersionOptions.VersionHeightPlaceholder) ?? false) + { + return SemanticVersion.Position.Prerelease; + } + else if (this.Version.Build == -1) + { + return SemanticVersion.Position.Build; + } + else if (this.Version.Revision == -1) + { + return SemanticVersion.Position.Revision; + } + else + { + return null; + } + } + } + /// /// Gets a value indicating whether this instance is the default "0.0" instance. /// @@ -223,6 +249,79 @@ public bool Equals(SemanticVersion other) && this.BuildMetadata == other.BuildMetadata; } + /// + /// Tests whether two instances are compatible enough that version height is not reset + /// when progressing from one to the next. + /// + /// The first semantic version. + /// The second semantic version. + /// The position within the version where height is tracked. + /// true if transitioning from one version to the next should reset the version height; false otherwise. + internal static bool WillVersionChangeResetVersionHeight(SemanticVersion first, SemanticVersion second, SemanticVersion.Position versionHeightPosition) + { + Requires.NotNull(first, nameof(first)); + Requires.NotNull(second, nameof(second)); + + if (first == second) + { + return false; + } + + if (versionHeightPosition == SemanticVersion.Position.Prerelease) + { + // The entire version spec must match exactly. + return !first.Equals(second); + } + + for (SemanticVersion.Position position = SemanticVersion.Position.Major; position <= versionHeightPosition; position++) + { + int expectedValue = ReadVersionPosition(second.Version, position); + int actualValue = ReadVersionPosition(first.Version, position); + if (expectedValue != actualValue) + { + return true; + } + } + + return false; + } + + internal static int ReadVersionPosition(Version version, SemanticVersion.Position position) + { + Requires.NotNull(version, nameof(version)); + + switch (position) + { + case SemanticVersion.Position.Major: + return version.Major; + case SemanticVersion.Position.Minor: + return version.Minor; + case SemanticVersion.Position.Build: + return version.Build; + case SemanticVersion.Position.Revision: + return version.Revision; + default: + throw new ArgumentOutOfRangeException(nameof(position), position, "Must be one of the 4 integer parts."); + } + } + + internal int ReadVersionPosition(SemanticVersion.Position position) + { + switch (position) + { + case SemanticVersion.Position.Major: + return this.Version.Major; + case SemanticVersion.Position.Minor: + return this.Version.Minor; + case SemanticVersion.Position.Build: + return this.Version.Build; + case SemanticVersion.Position.Revision: + return this.Version.Revision; + default: + throw new ArgumentOutOfRangeException(nameof(position), position, "Must be one of the 4 integer parts."); + } + } + /// /// Checks whether a particular version number /// belongs to the set of versions represented by this semantic version spec. diff --git a/src/NerdBank.GitVersioning/SemanticVersionJsonConverter.cs b/src/NerdBank.GitVersioning/SemanticVersionJsonConverter.cs index abd46b6d..a064cb19 100644 --- a/src/NerdBank.GitVersioning/SemanticVersionJsonConverter.cs +++ b/src/NerdBank.GitVersioning/SemanticVersionJsonConverter.cs @@ -1,11 +1,7 @@ namespace Nerdbank.GitVersioning { using System; - using System.Collections.Generic; - using System.Linq; using System.Reflection; - using System.Text; - using System.Threading.Tasks; using Newtonsoft.Json; internal class SemanticVersionJsonConverter : JsonConverter diff --git a/src/NerdBank.GitVersioning/VersionFile.cs b/src/NerdBank.GitVersioning/VersionFile.cs index ea2925cb..936fbb26 100644 --- a/src/NerdBank.GitVersioning/VersionFile.cs +++ b/src/NerdBank.GitVersioning/VersionFile.cs @@ -1,19 +1,16 @@ -namespace Nerdbank.GitVersioning -{ - using Newtonsoft.Json; - using Newtonsoft.Json.Converters; - using Newtonsoft.Json.Linq; - using Newtonsoft.Json.Serialization; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using Validation; +#nullable enable + +using System; +using System.IO; +using Newtonsoft.Json; +using Validation; +namespace Nerdbank.GitVersioning +{ /// - /// Extension methods for interacting with the version.txt file. + /// Exposes queries and mutations on a version.json or version.txt file. /// - public static class VersionFile + public abstract class VersionFile { /// /// The filename of the version.txt file. @@ -26,257 +23,44 @@ public static class VersionFile public const string JsonFileName = "version.json"; /// - /// A sequence of possible filenames for the version file in preferred order. + /// Initializes a new instance of the class. /// - public static readonly IReadOnlyList PreferredFileNames = new[] { JsonFileName, TxtFileName }; - - /// - /// Reads the version.json file and returns the and prerelease tag from it. - /// - /// The commit to read the version file from. - /// The directory to consider when searching for the version.txt file. - /// An optional blob cache for storing the raw parse results of a version.txt or version.json file (before any inherit merge operations are applied). - /// The version information read from the file. - public static VersionOptions GetVersion(LibGit2Sharp.Commit commit, string repoRelativeProjectDirectory = null, Dictionary blobVersionCache = null) + /// The git context to use when reading version files. + protected VersionFile(GitContext context) { - if (commit == null) - { - return null; - } - - string searchDirectory = repoRelativeProjectDirectory ?? string.Empty; - while (searchDirectory != null) - { - string parentDirectory = searchDirectory.Length > 0 ? Path.GetDirectoryName(searchDirectory) : null; - - string candidatePath = Path.Combine(searchDirectory, TxtFileName).Replace('\\', '/'); - var versionTxtBlob = commit.Tree[candidatePath]?.Target as LibGit2Sharp.Blob; - if (versionTxtBlob != null) - { - if (blobVersionCache is null || !blobVersionCache.TryGetValue(versionTxtBlob.Id, out VersionOptions result)) - { - result = TryReadVersionFile(new StreamReader(versionTxtBlob.GetContentStream())); - if (blobVersionCache is object) - { - result?.Freeze(); - blobVersionCache.Add(versionTxtBlob.Id, result); - } - } - - if (result != null) - { - return result; - } - } - - candidatePath = Path.Combine(searchDirectory, JsonFileName).Replace('\\', '/'); - var versionJsonBlob = commit.Tree[candidatePath]?.Target as LibGit2Sharp.Blob; - if (versionJsonBlob != null) - { - string versionJsonContent = null; - if (blobVersionCache is null || !blobVersionCache.TryGetValue(versionJsonBlob.Id, out VersionOptions result)) - { - using (var sr = new StreamReader(versionJsonBlob.GetContentStream())) - { - versionJsonContent = sr.ReadToEnd(); - } - - try - { - result = TryReadVersionJsonContent(versionJsonContent, searchDirectory); - } - catch (FormatException ex) - { - throw new FormatException( - $"Failure while reading {JsonFileName} from commit {commit.Sha}. " + - "Fix this commit with rebase if this is an error, or review this doc on how to migrate to Nerdbank.GitVersioning: " + - "https://github.com/dotnet/Nerdbank.GitVersioning/blob/master/doc/migrating.md", ex); - } - - if (blobVersionCache is object) - { - result?.Freeze(); - blobVersionCache.Add(versionJsonBlob.Id, result); - } - } - - if (result?.Inherit ?? false) - { - if (parentDirectory != null) - { - result = GetVersion(commit, parentDirectory, blobVersionCache); - if (result != null) - { - if (versionJsonContent is null) - { - // We reused a cache VersionOptions, but now we need the actual JSON string. - using (var sr = new StreamReader(versionJsonBlob.GetContentStream())) - { - versionJsonContent = sr.ReadToEnd(); - } - } - - if (result.IsFrozen) - { - result = new VersionOptions(result); - } - - JsonConvert.PopulateObject(versionJsonContent, result, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: searchDirectory)); - return result; - } - } - - throw new InvalidOperationException($"\"{candidatePath}\" inherits from a parent directory version.json file but none exists."); - } - else if (result != null) - { - return result; - } - } - - searchDirectory = parentDirectory; - } - - return null; - } - - /// - /// Reads the version.txt file and returns the and prerelease tag from it. - /// - /// The repo to read the version file from. - /// The directory to consider when searching for the version.txt file. - /// The version information read from the file. - public static VersionOptions GetVersion(LibGit2Sharp.Repository repo, string repoRelativeProjectDirectory = null) - { - if (repo == null) - { - return null; - } - - if (!repo.Info.IsBare) - { - string fullDirectory = Path.Combine(repo.Info.WorkingDirectory, repoRelativeProjectDirectory ?? string.Empty); - var workingCopyVersion = GetVersion(fullDirectory); - return workingCopyVersion; - } - - return GetVersion(repo.Head.Tip, repoRelativeProjectDirectory); + this.Context = context; } /// - /// Reads the version.txt file and returns the and prerelease tag from it. + /// Gets the git context to use when reading version files. /// - /// The path to the directory which may (or its ancestors may) define the version.txt file. - /// The version information read from the file, or null if the file wasn't found. - public static VersionOptions GetVersion(string projectDirectory) => GetVersion(projectDirectory, out string _); + protected GitContext Context { get; } /// - /// Reads the version.txt file and returns the and prerelease tag from it. + /// Checks whether a version file is defined. /// - /// The path to the directory which may (or its ancestors may) define the version.txt file. - /// Set to the actual directory that the version file was found in, which may be or one of its ancestors. - /// The version information read from the file, or null if the file wasn't found. - public static VersionOptions GetVersion(string projectDirectory, out string actualDirectory) - { - Requires.NotNullOrEmpty(projectDirectory, nameof(projectDirectory)); - using (var repo = GitExtensions.OpenGitRepo(projectDirectory)) - { - string searchDirectory = projectDirectory; - while (searchDirectory != null) - { - string parentDirectory = Path.GetDirectoryName(searchDirectory); - string versionTxtPath = Path.Combine(searchDirectory, TxtFileName); - if (File.Exists(versionTxtPath)) - { - using (var sr = new StreamReader(File.OpenRead(versionTxtPath))) - { - var result = TryReadVersionFile(sr); - if (result != null) - { - actualDirectory = searchDirectory; - return result; - } - } - } - - string versionJsonPath = Path.Combine(searchDirectory, JsonFileName); - if (File.Exists(versionJsonPath)) - { - string versionJsonContent = File.ReadAllText(versionJsonPath); + /// true if the version file is found; otherwise false. + public bool IsVersionDefined() => this.GetVersion() is object; - var repoRelativeBaseDirectory = repo?.GetRepoRelativePath(searchDirectory); - VersionOptions result = - TryReadVersionJsonContent(versionJsonContent, repoRelativeBaseDirectory); - if (result?.Inherit ?? false) - { - if (parentDirectory != null) - { - result = GetVersion(parentDirectory); - if (result != null) - { - JsonConvert.PopulateObject(versionJsonContent, result, - VersionOptions.GetJsonSettings( - repoRelativeBaseDirectory: repoRelativeBaseDirectory)); - actualDirectory = searchDirectory; - return result; - } - } - - throw new InvalidOperationException( - $"\"{versionJsonPath}\" inherits from a parent directory version.json file but none exists."); - } - else if (result != null) - { - actualDirectory = searchDirectory; - return result; - } - } - - searchDirectory = parentDirectory; - } - } - - actualDirectory = null; - return null; - } + /// + public VersionOptions? GetWorkingCopyVersion() => this.GetWorkingCopyVersion(out string _); /// - /// Checks whether the version.txt file is defined in the specified commit. + /// Reads the version file from the working tree and returns the deserialized from it. /// - /// The commit to search. - /// The directory to consider when searching for the version.txt file. - /// true if the version.txt file is found; otherwise false. - public static bool IsVersionDefined(LibGit2Sharp.Commit commit, string projectDirectory = null) - { - return GetVersion(commit, projectDirectory) != null; - } + /// Set to the actual directory that the version file was found in, which may be or one of its ancestors. + /// The version information read from the file, or null if the file wasn't found. + public VersionOptions? GetWorkingCopyVersion(out string? actualDirectory) => this.GetWorkingCopyVersion(this.Context.AbsoluteProjectDirectory, out actualDirectory); - /// - /// Checks whether the version.txt file is defined in the specified project directory - /// or one of its ancestors. - /// - /// The directory to start searching within. - /// true if the version.txt file is found; otherwise false. - public static bool IsVersionDefined(string projectDirectory) + /// + /// The optional unstable tag to include in the file. +#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do) + public string SetVersion(string projectDirectory, System.Version version, string? unstableTag = null, bool includeSchemaProperty = false) +#pragma warning restore CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do) { - Requires.NotNullOrEmpty(projectDirectory, nameof(projectDirectory)); - - return GetVersion(projectDirectory) != null; + return this.SetVersion(projectDirectory, VersionOptions.FromVersion(version, unstableTag), includeSchemaProperty); } - /// - /// Writes the version.json file to a directory within a repo with the specified version information. - /// The $schema property is included. - /// - /// - /// The path to the directory in which to write the version.json file. - /// The file's impact will be all descendent projects and directories from this specified directory, - /// except where any of those directories have their own version.json file. - /// - /// The version information to write to the file. - /// The path to the file written. - public static string SetVersion(string projectDirectory, VersionOptions version) => SetVersion(projectDirectory, version, includeSchemaProperty: true); - /// /// Writes the version.json file to a directory within a repo with the specified version information. /// @@ -288,11 +72,11 @@ public static bool IsVersionDefined(string projectDirectory) /// The version information to write to the file. /// A value indicating whether to serialize the $schema property for easier editing in most JSON editors. /// The path to the file written. - public static string SetVersion(string projectDirectory, VersionOptions version, bool includeSchemaProperty) + public string SetVersion(string projectDirectory, VersionOptions version, bool includeSchemaProperty = true) { Requires.NotNullOrEmpty(projectDirectory, nameof(projectDirectory)); Requires.NotNull(version, nameof(version)); - Requires.Argument(version.Version != null || version.Inherit, nameof(version), $"{nameof(VersionOptions.Version)} must be set for a root-level version.json file."); + Requires.Argument(version.Version is object || version.Inherit, nameof(version), $"{nameof(VersionOptions.Version)} must be set for a root-level version.json file."); Directory.CreateDirectory(projectDirectory); @@ -303,7 +87,7 @@ public static string SetVersion(string projectDirectory, VersionOptions version, { File.WriteAllLines( versionTxtPath, - new[] { version.Version.Version.ToString(), version.Version.Prerelease }); + new[] { version.Version?.Version.ToString(), version.Version?.Prerelease }); return versionTxtPath; } else @@ -313,32 +97,54 @@ public static string SetVersion(string projectDirectory, VersionOptions version, } } - using (var repo = GitExtensions.OpenGitRepo(projectDirectory)) - { - string repoRelativeProjectDirectory = repo?.GetRepoRelativePath(projectDirectory); - string versionJsonPath = Path.Combine(projectDirectory, JsonFileName); - var jsonContent = JsonConvert.SerializeObject(version, - VersionOptions.GetJsonSettings(version.Inherit, includeSchemaProperty, - repoRelativeProjectDirectory)); - File.WriteAllText(versionJsonPath, jsonContent); - return versionJsonPath; - } + string repoRelativeProjectDirectory = this.Context.GetRepoRelativePath(projectDirectory); + string versionJsonPath = Path.Combine(projectDirectory, JsonFileName); + string jsonContent = JsonConvert.SerializeObject( + version, + VersionOptions.GetJsonSettings(version.Inherit, includeSchemaProperty, repoRelativeProjectDirectory)); + File.WriteAllText(versionJsonPath, jsonContent); + return versionJsonPath; } /// - /// Writes the version.txt file to a directory within a repo with the specified version information. + /// Reads the version file from in the and returns the deserialized from it. /// - /// - /// The path to the directory in which to write the version.txt file. - /// The file's impact will be all descendent projects and directories from this specified directory, - /// except where any of those directories have their own version.txt file. - /// - /// The version information to write to the file. - /// The optional unstable tag to include in the file. - /// The path to the file written. - public static string SetVersion(string projectDirectory, Version version, string unstableTag = null) + /// Receives the absolute path to the directory where the version file was found, if any. + /// The version information read from the file, or if the file wasn't found. + /// This method is only called if is not null. + protected abstract VersionOptions? GetVersionCore(out string? actualDirectory); + + /// + public VersionOptions? GetVersion() => this.GetVersion(out string? actualDirectory); + + /// + /// Reads the version file from the selected git commit (or working copy if no commit is selected) and returns the deserialized from it. + /// + /// Receives the absolute path to the directory where the version file was found, if any. + /// The version information read from the file, or if the file wasn't found. + public VersionOptions? GetVersion(out string? actualDirectory) + { + return this.Context.GitCommitId is null + ? this.GetWorkingCopyVersion(out actualDirectory) + : this.GetVersionCore(out actualDirectory); + } + + /// + /// Tries to read a version.json file from the specified string, but favors returning null instead of throwing a . + /// + /// The content of the version.json file. + /// Directory that this version.json file is relative to the root of the repository. + /// The deserialized object, if deserialization was successful. + protected static VersionOptions? TryReadVersionJsonContent(string jsonContent, string? repoRelativeBaseDirectory) { - return SetVersion(projectDirectory, VersionOptions.FromVersion(version, unstableTag), includeSchemaProperty: false); + try + { + return JsonConvert.DeserializeObject(jsonContent, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: repoRelativeBaseDirectory)); + } + catch (JsonSerializationException) + { + return null; + } } /// @@ -346,10 +152,10 @@ public static string SetVersion(string projectDirectory, Version version, string /// /// The content of the version.txt file to read. /// The version information read from the file; or null if a deserialization error occurs. - private static VersionOptions TryReadVersionFile(TextReader versionTextContent) + protected static VersionOptions TryReadVersionFile(TextReader versionTextContent) { - string versionLine = versionTextContent.ReadLine(); - string prereleaseVersion = versionTextContent.ReadLine(); + string? versionLine = versionTextContent.ReadLine(); + string? prereleaseVersion = versionTextContent.ReadLine(); if (!string.IsNullOrEmpty(prereleaseVersion)) { if (!prereleaseVersion.StartsWith("-")) @@ -368,21 +174,72 @@ private static VersionOptions TryReadVersionFile(TextReader versionTextContent) } /// - /// Tries to read a version.json file from the specified string, but favors returning null instead of throwing a . + /// Reads a version file from the working tree, without any regard to a git repo. /// - /// The content of the version.json file. - /// Directory that this version.json file is relative to the root of the repository. - /// The deserialized object, if deserialization was successful. - private static VersionOptions TryReadVersionJsonContent(string jsonContent, string repoRelativeBaseDirectory) + /// The path to start the search from. + /// Receives the directory where the version file was found. + /// The version options, if found. + protected VersionOptions? GetWorkingCopyVersion(string startingDirectory, out string? actualDirectory) { - try - { - return JsonConvert.DeserializeObject(jsonContent, VersionOptions.GetJsonSettings(repoRelativeBaseDirectory: repoRelativeBaseDirectory)); - } - catch (JsonSerializationException) + string? searchDirectory = startingDirectory; + while (searchDirectory is object) { - return null; + // Do not search above the working tree root. + string? parentDirectory = string.Equals(searchDirectory, this.Context.WorkingTreePath, StringComparison.OrdinalIgnoreCase) + ? null + : Path.GetDirectoryName(searchDirectory); + string versionTxtPath = Path.Combine(searchDirectory, TxtFileName); + if (File.Exists(versionTxtPath)) + { + using (var sr = new StreamReader(File.OpenRead(versionTxtPath))) + { + var result = TryReadVersionFile(sr); + if (result is object) + { + actualDirectory = searchDirectory; + return result; + } + } + } + + string versionJsonPath = Path.Combine(searchDirectory, JsonFileName); + if (File.Exists(versionJsonPath)) + { + string versionJsonContent = File.ReadAllText(versionJsonPath); + + var repoRelativeBaseDirectory = this.Context.GetRepoRelativePath(searchDirectory); + VersionOptions? result = + TryReadVersionJsonContent(versionJsonContent, repoRelativeBaseDirectory); + if (result?.Inherit ?? false) + { + if (parentDirectory is object) + { + result = this.GetWorkingCopyVersion(parentDirectory, out string _); + if (result is object) + { + JsonConvert.PopulateObject(versionJsonContent, result, + VersionOptions.GetJsonSettings( + repoRelativeBaseDirectory: repoRelativeBaseDirectory)); + actualDirectory = searchDirectory; + return result; + } + } + + throw new InvalidOperationException( + $"\"{versionJsonPath}\" inherits from a parent directory version.json file but none exists."); + } + else if (result is object) + { + actualDirectory = searchDirectory; + return result; + } + } + + searchDirectory = parentDirectory; } + + actualDirectory = null; + return null; } } } diff --git a/src/NerdBank.GitVersioning/VersionOptions.cs b/src/NerdBank.GitVersioning/VersionOptions.cs index b8fd4215..7aee1dfa 100644 --- a/src/NerdBank.GitVersioning/VersionOptions.cs +++ b/src/NerdBank.GitVersioning/VersionOptions.cs @@ -1,17 +1,18 @@ -using LibGit2Sharp; +#nullable enable namespace Nerdbank.GitVersioning { using System; using System.Collections.Generic; using System.Collections.ObjectModel; - using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Reflection; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Validation; + using EditorBrowsableAttribute = System.ComponentModel.EditorBrowsableAttribute; + using EditorBrowsableState = System.ComponentModel.EditorBrowsableState; /// /// Describes the various versions and options required for the build. @@ -26,19 +27,19 @@ public class VersionOptions : IEquatable private bool isFrozen; [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string gitCommitIdPrefix; + private string? gitCommitIdPrefix; /// /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private SemanticVersion version; + private SemanticVersion? version; /// /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private AssemblyVersionOptions assemblyVersion; + private AssemblyVersionOptions? assemblyVersion; /// /// Backing field for the property. @@ -68,31 +69,31 @@ public class VersionOptions : IEquatable /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private NuGetPackageVersionOptions nuGetPackageVersion; + private NuGetPackageVersionOptions? nuGetPackageVersion; /// /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private IReadOnlyList publicReleaseRefSpec; + private IReadOnlyList? publicReleaseRefSpec; /// /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private CloudBuildOptions cloudBuild; + private CloudBuildOptions? cloudBuild; /// /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private ReleaseOptions release; + private ReleaseOptions? release; /// /// Backing field for the property. /// [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private IReadOnlyList pathFilters; + private IReadOnlyList? pathFilters; /// /// Backing field for the property. @@ -164,7 +165,7 @@ public VersionOptions(VersionOptions copyFrom) /// Gets or sets the default version to use. /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public SemanticVersion Version + public SemanticVersion? Version { get => this.version; set => this.SetIfNotReadOnly(ref this.version, value); @@ -176,7 +177,7 @@ public SemanticVersion Version /// /// An instance of or null to simply use the default . [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public AssemblyVersionOptions AssemblyVersion + public AssemblyVersionOptions? AssemblyVersion { get => this.assemblyVersion; set => this.SetIfNotReadOnly(ref this.assemblyVersion, value); @@ -189,7 +190,7 @@ public AssemblyVersionOptions AssemblyVersion /// /// A prefix for git commit id. [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string GitCommitIdPrefix + public string? GitCommitIdPrefix { get => this.gitCommitIdPrefix; set @@ -198,7 +199,7 @@ public string GitCommitIdPrefix { throw new ArgumentNullException(nameof(value), $"{nameof(this.GitCommitIdPrefix)} can't be empty"); } - char first = value[0]; + char first = value![0]; if (first < 'A' || (first > 'Z' && first < 'a' && first != '_') || first > 'z') { throw new ArgumentException(nameof(value), $"{nameof(this.GitCommitIdPrefix)} must lead with a [A-z_] character (not a number)"); @@ -323,7 +324,7 @@ public int? GitCommitIdShortAutoMinimum /// Gets or sets the options around NuGet version strings /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public NuGetPackageVersionOptions NuGetPackageVersion + public NuGetPackageVersionOptions? NuGetPackageVersion { get => this.nuGetPackageVersion; set => this.SetIfNotReadOnly(ref this.nuGetPackageVersion, value); @@ -340,7 +341,7 @@ public NuGetPackageVersionOptions NuGetPackageVersion /// be built with PublicRelease=true as the default value on build servers. /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public IReadOnlyList PublicReleaseRefSpec + public IReadOnlyList? PublicReleaseRefSpec { get => this.publicReleaseRefSpec; set => this.SetIfNotReadOnly(ref this.publicReleaseRefSpec, value); @@ -357,7 +358,7 @@ public IReadOnlyList PublicReleaseRefSpec /// Gets or sets the options around cloud build. /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public CloudBuildOptions CloudBuild + public CloudBuildOptions? CloudBuild { get => this.cloudBuild; set => this.SetIfNotReadOnly(ref this.cloudBuild, value); @@ -373,7 +374,7 @@ public CloudBuildOptions CloudBuild /// Gets or sets the options for the prepare-release command /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public ReleaseOptions Release + public ReleaseOptions? Release { get => this.release; set => this.SetIfNotReadOnly(ref this.release, value); @@ -391,7 +392,7 @@ public ReleaseOptions Release /// Paths should be relative to the root of the repository. /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public IReadOnlyList PathFilters + public IReadOnlyList? PathFilters { get => this.pathFilters; set => this.SetIfNotReadOnly(ref this.pathFilters, value); @@ -424,22 +425,7 @@ internal SemanticVersion.Position? VersionHeightPosition { get { - if (this.Version?.Prerelease?.Contains(VersionHeightPlaceholder) ?? false) - { - return SemanticVersion.Position.Prerelease; - } - else if (this.Version?.Version.Build == -1) - { - return SemanticVersion.Position.Build; - } - else if (this.Version?.Version.Revision == -1) - { - return SemanticVersion.Position.Revision; - } - else - { - return null; - } + return this.version?.VersionHeightPosition; } } @@ -478,7 +464,7 @@ internal SemanticVersion.Position? GitCommitIdPosition /// The version number. /// The prerelease tag, if any. /// The new instance of . - public static VersionOptions FromVersion(Version version, string unstableTag = null) + public static VersionOptions FromVersion(Version version, string? unstableTag = null) { return new VersionOptions { @@ -516,7 +502,7 @@ public static VersionOptions FromVersion(Version version, string unstableTag = n /// Passing null will mean path filters cannot be serialized. /// /// The serializer settings to use. - public static JsonSerializerSettings GetJsonSettings(bool includeDefaults = false, bool includeSchemaProperty = false, string repoRelativeBaseDirectory = null) + public static JsonSerializerSettings GetJsonSettings(bool includeDefaults = false, bool includeSchemaProperty = false, string? repoRelativeBaseDirectory = null) { return new JsonSerializerSettings { @@ -541,7 +527,7 @@ public static JsonSerializerSettings GetJsonSettings(bool includeDefaults = fals /// /// The other instance. /// true if the instances have equal values; false otherwise. - public override bool Equals(object obj) + public override bool Equals(object? obj) { return this.Equals(obj as VersionOptions); } @@ -557,7 +543,7 @@ public override bool Equals(object obj) /// /// The other instance. /// true if the instances have equal values; false otherwise. - public bool Equals(VersionOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(VersionOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// /// Freezes this instance so no more changes can be made to it. @@ -586,7 +572,7 @@ internal bool IsDefaultVersionTheOnlyPropertySet { return this.Version != null && this.AssemblyVersion == null - && this.CloudBuild.IsDefault + && (this.CloudBuild?.IsDefault ?? true) && this.VersionHeightOffset == 0 && !this.SemVer1NumericIdentifierPadding.HasValue && !this.Inherit; @@ -668,10 +654,10 @@ public float? SemVer public void Freeze() => this.isFrozen = true; /// - public override bool Equals(object obj) => this.Equals(obj as NuGetPackageVersionOptions); + public override bool Equals(object? obj) => this.Equals(obj as NuGetPackageVersionOptions); /// - public bool Equals(NuGetPackageVersionOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(NuGetPackageVersionOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// public override int GetHashCode() => EqualWithDefaultsComparer.Singleton.GetHashCode(this); @@ -693,32 +679,32 @@ private void SetIfNotReadOnly(ref T field, T value) field = value; } - internal class EqualWithDefaultsComparer : IEqualityComparer + internal class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(NuGetPackageVersionOptions x, NuGetPackageVersionOptions y) + public bool Equals(NuGetPackageVersionOptions? x, NuGetPackageVersionOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x == null || y == null) { - return true; + return false; } return x.SemVerOrDefault == y.SemVerOrDefault; } /// - public int GetHashCode(NuGetPackageVersionOptions obj) + public int GetHashCode(NuGetPackageVersionOptions? obj) { - return obj.SemVerOrDefault.GetHashCode(); + return obj?.SemVerOrDefault.GetHashCode() ?? 0; } } } @@ -741,7 +727,7 @@ public class AssemblyVersionOptions : IEquatable private bool isFrozen; [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private Version version; + private Version? version; [DebuggerBrowsable(DebuggerBrowsableState.Never)] private VersionPrecision? precision; @@ -777,7 +763,7 @@ public AssemblyVersionOptions(AssemblyVersionOptions copyFrom) /// Gets or sets the major.minor components of the assembly version. /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public Version Version + public Version? Version { get => this.version; set => this.SetIfNotReadOnly(ref this.version, value); @@ -811,10 +797,10 @@ public VersionPrecision? Precision public void Freeze() => this.isFrozen = true; /// - public override bool Equals(object obj) => this.Equals(obj as AssemblyVersionOptions); + public override bool Equals(object? obj) => this.Equals(obj as AssemblyVersionOptions); /// - public bool Equals(AssemblyVersionOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(AssemblyVersionOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// public override int GetHashCode() => EqualWithDefaultsComparer.Singleton.GetHashCode(this); @@ -836,32 +822,37 @@ private void SetIfNotReadOnly(ref T field, T value) field = value; } - internal class EqualWithDefaultsComparer : IEqualityComparer + internal class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(AssemblyVersionOptions x, AssemblyVersionOptions y) + public bool Equals(AssemblyVersionOptions? x, AssemblyVersionOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x is null || y is null) { - return true; + return false; } - return EqualityComparer.Default.Equals(x.Version, y.Version) + return EqualityComparer.Default.Equals(x.Version, y.Version) && x.PrecisionOrDefault == y.PrecisionOrDefault; } /// - public int GetHashCode(AssemblyVersionOptions obj) + public int GetHashCode(AssemblyVersionOptions? obj) { + if (obj is null) + { + return 0; + } + return (obj.Version?.GetHashCode() ?? 0) + (int)obj.PrecisionOrDefault; } } @@ -892,7 +883,7 @@ public class CloudBuildOptions : IEquatable private bool? setVersionVariables; [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private CloudBuildNumberOptions buildNumber; + private CloudBuildNumberOptions? buildNumber; /// /// Initializes a new instance of the class. @@ -934,18 +925,18 @@ public bool? SetVersionVariables /// Gets a value indicating whether to elevate all build properties to cloud build variables prefaced with "NBGV_". /// [JsonIgnore] - public bool SetAllVariablesOrDefault => this.SetAllVariables ?? DefaultInstance.SetAllVariables.Value; + public bool SetAllVariablesOrDefault => this.SetAllVariables ?? DefaultInstance.SetAllVariables!.Value; /// /// Gets a value indicating whether to elevate certain calculated version build properties to cloud build variables. /// [JsonIgnore] - public bool SetVersionVariablesOrDefault => this.SetVersionVariables ?? DefaultInstance.SetVersionVariables.Value; + public bool SetVersionVariablesOrDefault => this.SetVersionVariables ?? DefaultInstance.SetVersionVariables!.Value; /// /// Gets or sets options around how and whether to set the build number preset by the cloud build with one enriched with version information. /// - public CloudBuildNumberOptions BuildNumber + public CloudBuildNumberOptions? BuildNumber { get => this.buildNumber; set => this.SetIfNotReadOnly(ref this.buildNumber, value); @@ -976,10 +967,10 @@ public void Freeze() } /// - public override bool Equals(object obj) => this.Equals(obj as CloudBuildOptions); + public override bool Equals(object? obj) => this.Equals(obj as CloudBuildOptions); /// - public bool Equals(CloudBuildOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(CloudBuildOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// public override int GetHashCode() => EqualWithDefaultsComparer.Singleton.GetHashCode(this); @@ -1001,23 +992,23 @@ private void SetIfNotReadOnly(ref T field, T value) field = value; } - internal class EqualWithDefaultsComparer : IEqualityComparer + internal class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(CloudBuildOptions x, CloudBuildOptions y) + public bool Equals(CloudBuildOptions? x, CloudBuildOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x is null || y is null) { - return true; + return false; } return x.SetVersionVariablesOrDefault == y.SetVersionVariablesOrDefault @@ -1026,8 +1017,13 @@ public bool Equals(CloudBuildOptions x, CloudBuildOptions y) } /// - public int GetHashCode(CloudBuildOptions obj) + public int GetHashCode(CloudBuildOptions? obj) { + if (obj is null) + { + return 0; + } + return (obj.SetVersionVariablesOrDefault ? 1 : 0) + (obj.SetAllVariablesOrDefault ? 1 : 0) + obj.BuildNumberOrDefault.GetHashCode(); @@ -1054,7 +1050,7 @@ public class CloudBuildNumberOptions : IEquatable [DebuggerBrowsable(DebuggerBrowsableState.Never)] private bool? enabled; - private CloudBuildNumberCommitIdOptions includeCommitId; + private CloudBuildNumberCommitIdOptions? includeCommitId; /// /// Initializes a new instance of the class. @@ -1085,12 +1081,12 @@ public bool? Enabled /// Gets a value indicating whether to override the build number preset by the cloud build. /// [JsonIgnore] - public bool EnabledOrDefault => this.Enabled ?? DefaultInstance.Enabled.Value; + public bool EnabledOrDefault => this.Enabled ?? DefaultInstance.Enabled!.Value; /// /// Gets or sets when and where to include information about the git commit being built. /// - public CloudBuildNumberCommitIdOptions IncludeCommitId + public CloudBuildNumberCommitIdOptions? IncludeCommitId { get => this.includeCommitId; set => this.SetIfNotReadOnly(ref this.includeCommitId, value); @@ -1121,10 +1117,10 @@ public void Freeze() } /// - public override bool Equals(object obj) => this.Equals(obj as CloudBuildNumberOptions); + public override bool Equals(object? obj) => this.Equals(obj as CloudBuildNumberOptions); /// - public bool Equals(CloudBuildNumberOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(CloudBuildNumberOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// public override int GetHashCode() => EqualWithDefaultsComparer.Singleton.GetHashCode(this); @@ -1146,23 +1142,23 @@ private void SetIfNotReadOnly(ref T field, T value) field = value; } - internal class EqualWithDefaultsComparer : IEqualityComparer + internal class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(CloudBuildNumberOptions x, CloudBuildNumberOptions y) + public bool Equals(CloudBuildNumberOptions? x, CloudBuildNumberOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x is null || y is null) { - return true; + return false; } return x.EnabledOrDefault == y.EnabledOrDefault @@ -1170,8 +1166,13 @@ public bool Equals(CloudBuildNumberOptions x, CloudBuildNumberOptions y) } /// - public int GetHashCode(CloudBuildNumberOptions obj) + public int GetHashCode(CloudBuildNumberOptions? obj) { + if (obj is null) + { + return 0; + } + return obj.EnabledOrDefault ? 1 : 0 + obj.IncludeCommitIdOrDefault.GetHashCode(); } @@ -1231,7 +1232,7 @@ public CloudBuildNumberCommitWhen? When /// Gets the conditions when the commit ID is included in the build number. /// [JsonIgnore] - public CloudBuildNumberCommitWhen WhenOrDefault => this.When ?? DefaultInstance.When.Value; + public CloudBuildNumberCommitWhen WhenOrDefault => this.When ?? DefaultInstance.When!.Value; /// /// Gets or sets the position to include the commit ID information. @@ -1246,7 +1247,7 @@ public CloudBuildNumberCommitWhere? Where /// Gets the position to include the commit ID information. /// [JsonIgnore] - public CloudBuildNumberCommitWhere WhereOrDefault => this.Where ?? DefaultInstance.Where.Value; + public CloudBuildNumberCommitWhere WhereOrDefault => this.Where ?? DefaultInstance.Where!.Value; /// /// Gets a value indicating whether this instance rejects all attempts to mutate it. @@ -1260,10 +1261,10 @@ public CloudBuildNumberCommitWhere? Where public void Freeze() => this.isFrozen = true; /// - public override bool Equals(object obj) => this.Equals(obj as CloudBuildNumberCommitIdOptions); + public override bool Equals(object? obj) => this.Equals(obj as CloudBuildNumberCommitIdOptions); /// - public bool Equals(CloudBuildNumberCommitIdOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(CloudBuildNumberCommitIdOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// public override int GetHashCode() => EqualWithDefaultsComparer.Singleton.GetHashCode(this); @@ -1284,23 +1285,23 @@ private void SetIfNotReadOnly(ref T field, T value) field = value; } - internal class EqualWithDefaultsComparer : IEqualityComparer + internal class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(CloudBuildNumberCommitIdOptions x, CloudBuildNumberCommitIdOptions y) + public bool Equals(CloudBuildNumberCommitIdOptions? x, CloudBuildNumberCommitIdOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x is null || y is null) { - return true; + return false; } return x.WhenOrDefault == y.WhenOrDefault @@ -1308,33 +1309,38 @@ public bool Equals(CloudBuildNumberCommitIdOptions x, CloudBuildNumberCommitIdOp } /// - public int GetHashCode(CloudBuildNumberCommitIdOptions obj) + public int GetHashCode(CloudBuildNumberCommitIdOptions? obj) { + if (obj is null) + { + return 0; + } + return (int)obj.WhereOrDefault + (int)obj.WhenOrDefault * 0x10; } } } - private class EqualWithDefaultsComparer : IEqualityComparer + private class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(VersionOptions x, VersionOptions y) + public bool Equals(VersionOptions? x, VersionOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x is null || y is null) { - return true; + return false; } - return EqualityComparer.Default.Equals(x.Version, y.Version) + return EqualityComparer.Default.Equals(x.Version, y.Version) && AssemblyVersionOptions.EqualWithDefaultsComparer.Singleton.Equals(x.AssemblyVersionOrDefault, y.AssemblyVersionOrDefault) && NuGetPackageVersionOptions.EqualWithDefaultsComparer.Singleton.Equals(x.NuGetPackageVersionOrDefault, y.NuGetPackageVersionOrDefault) && CloudBuildOptions.EqualWithDefaultsComparer.Singleton.Equals(x.CloudBuildOrDefault, y.CloudBuildOrDefault) @@ -1343,9 +1349,9 @@ public bool Equals(VersionOptions x, VersionOptions y) } /// - public int GetHashCode(VersionOptions obj) + public int GetHashCode(VersionOptions? obj) { - return obj.Version?.GetHashCode() ?? 0; + return obj?.Version?.GetHashCode() ?? 0; } } @@ -1432,13 +1438,13 @@ public class ReleaseOptions : IEquatable private bool isFrozen; [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string branchName; + private string? branchName; [DebuggerBrowsable(DebuggerBrowsableState.Never)] private ReleaseVersionIncrement? versionIncrement; [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string firstUnstableTag; + private string? firstUnstableTag; /// /// Initializes a new instance of the class @@ -1461,7 +1467,7 @@ public ReleaseOptions(ReleaseOptions copyFrom) /// Gets or sets the branch name template for release branches /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string BranchName + public string? BranchName { get => this.branchName; set => this.SetIfNotReadOnly(ref this.branchName, value); @@ -1471,7 +1477,7 @@ public string BranchName /// Gets the set branch name template for release branches /// [JsonIgnore] - public string BranchNameOrDefault => this.BranchName ?? DefaultInstance.BranchName; + public string BranchNameOrDefault => this.BranchName ?? DefaultInstance.BranchName!; /// /// Gets or sets the setting specifying how to increment the version when creating a release @@ -1487,13 +1493,13 @@ public ReleaseVersionIncrement? VersionIncrement /// Gets or sets the setting specifying how to increment the version when creating a release. /// [JsonIgnore] - public ReleaseVersionIncrement VersionIncrementOrDefault => this.VersionIncrement ?? DefaultInstance.VersionIncrement.Value; + public ReleaseVersionIncrement VersionIncrementOrDefault => this.VersionIncrement ?? DefaultInstance.VersionIncrement!.Value; /// /// Gets or sets the first/default prerelease tag for new versions. /// [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string FirstUnstableTag + public string? FirstUnstableTag { get => this.firstUnstableTag; set => this.SetIfNotReadOnly(ref this.firstUnstableTag, value); @@ -1503,7 +1509,7 @@ public string FirstUnstableTag /// Gets or sets the first/default prerelease tag for new versions. /// [JsonIgnore] - public string FirstUnstableTagOrDefault => this.FirstUnstableTag ?? DefaultInstance.FirstUnstableTag; + public string FirstUnstableTagOrDefault => this.FirstUnstableTag ?? DefaultInstance.FirstUnstableTag!; /// /// Gets a value indicating whether this instance rejects all attempts to mutate it. @@ -1517,10 +1523,10 @@ public string FirstUnstableTag public void Freeze() => this.isFrozen = true; /// - public override bool Equals(object obj) => this.Equals(obj as ReleaseOptions); + public override bool Equals(object? obj) => this.Equals(obj as ReleaseOptions); /// - public bool Equals(ReleaseOptions other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); + public bool Equals(ReleaseOptions? other) => EqualWithDefaultsComparer.Singleton.Equals(this, other); /// public override int GetHashCode() => EqualWithDefaultsComparer.Singleton.GetHashCode(this); @@ -1542,23 +1548,23 @@ private void SetIfNotReadOnly(ref T field, T value) field = value; } - internal class EqualWithDefaultsComparer : IEqualityComparer + internal class EqualWithDefaultsComparer : IEqualityComparer { internal static readonly EqualWithDefaultsComparer Singleton = new EqualWithDefaultsComparer(); private EqualWithDefaultsComparer() { } /// - public bool Equals(ReleaseOptions x, ReleaseOptions y) + public bool Equals(ReleaseOptions? x, ReleaseOptions? y) { - if (x == null ^ y == null) + if (ReferenceEquals(x, y)) { - return false; + return true; } - if (x == null) + if (x is null || y is null) { - return true; + return false; } return StringComparer.Ordinal.Equals(x.BranchNameOrDefault, y.BranchNameOrDefault) && @@ -1567,7 +1573,7 @@ public bool Equals(ReleaseOptions x, ReleaseOptions y) } /// - public int GetHashCode(ReleaseOptions obj) + public int GetHashCode(ReleaseOptions? obj) { if (obj == null) return 0; diff --git a/src/NerdBank.GitVersioning/VersionOptionsContractResolver.cs b/src/NerdBank.GitVersioning/VersionOptionsContractResolver.cs index 255c6ef5..00944f67 100644 --- a/src/NerdBank.GitVersioning/VersionOptionsContractResolver.cs +++ b/src/NerdBank.GitVersioning/VersionOptionsContractResolver.cs @@ -2,10 +2,7 @@ { using System; using System.Collections.Generic; - using System.Linq; using System.Reflection; - using System.Text; - using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; diff --git a/src/NerdBank.GitVersioning/VersionOracle.cs b/src/NerdBank.GitVersioning/VersionOracle.cs index 781dbc2d..84c83019 100644 --- a/src/NerdBank.GitVersioning/VersionOracle.cs +++ b/src/NerdBank.GitVersioning/VersionOracle.cs @@ -1,124 +1,100 @@ -namespace Nerdbank.GitVersioning +#nullable enable + +namespace Nerdbank.GitVersioning { using System; using System.Collections.Generic; using System.Globalization; - using System.IO; using System.Linq; using System.Reflection; - using System.Runtime.InteropServices; using System.Text.RegularExpressions; - using Validation; /// /// Assembles version information in a variety of formats. /// public class VersionOracle { + private const bool UseLibGit2 = false; + /// /// The 0.0 version. /// - private static readonly Version Version0 = new Version(0, 0); + private protected static readonly Version Version0 = new Version(0, 0); - /// - /// The 0.0 semver. - /// - private static readonly SemanticVersion SemVer0 = SemanticVersion.Parse("0.0"); + private readonly GitContext context; - /// - /// Initializes a new instance of the class. - /// - public static VersionOracle Create(string projectDirectory, string gitRepoDirectory = null, ICloudBuild cloudBuild = null, int? overrideBuildNumberOffset = null, string projectPathRelativeToGitRepoRoot = null) - { - Requires.NotNull(projectDirectory, nameof(projectDirectory)); - if (string.IsNullOrEmpty(gitRepoDirectory)) - { - gitRepoDirectory = projectDirectory; - } - - using (var git = GitExtensions.OpenGitRepo(gitRepoDirectory)) - { - return new VersionOracle(projectDirectory, git, null, cloudBuild, overrideBuildNumberOffset, projectPathRelativeToGitRepoRoot); - } - } + private readonly ICloudBuild? cloudBuild; /// /// Initializes a new instance of the class. /// - public VersionOracle(string projectDirectory, LibGit2Sharp.Repository repo, ICloudBuild cloudBuild, int? overrideBuildNumberOffset = null, string projectPathRelativeToGitRepoRoot = null) - : this(projectDirectory, repo, null, cloudBuild, overrideBuildNumberOffset, projectPathRelativeToGitRepoRoot) + /// The git context from which to calculate version data. + /// An optional cloud build provider that may offer additional context. Typically set to . + /// An optional value to override the version height offset. + public VersionOracle(GitContext context, ICloudBuild? cloudBuild = null, int? overrideVersionHeightOffset = null) { - } + this.context = context; + this.cloudBuild = cloudBuild; - /// - /// Initializes a new instance of the class. - /// - public VersionOracle(string projectDirectory, LibGit2Sharp.Repository repo, LibGit2Sharp.Commit head, ICloudBuild cloudBuild, int? overrideVersionHeightOffset = null, string projectPathRelativeToGitRepoRoot = null) - { - var relativeRepoProjectDirectory = projectPathRelativeToGitRepoRoot ?? repo?.GetRepoRelativePath(projectDirectory); - if (repo is object) - { - // If we're particularly git focused, normalize/reset projectDirectory to be the path we *actually* want to look at in case we're being redirected. - projectDirectory = Path.Combine(repo.Info.WorkingDirectory, relativeRepoProjectDirectory); - } - - var commit = head ?? repo?.Head.Tip; + this.CommittedVersion = context.VersionFile.GetVersion(); - var committedVersion = VersionFile.GetVersion(commit, relativeRepoProjectDirectory); - - var workingVersion = head is object ? VersionFile.GetVersion(head, relativeRepoProjectDirectory) : VersionFile.GetVersion(projectDirectory); + // Consider the working version only if the commit being inspected is HEAD. + // Otherwise we're looking at historical data and should not consider the state of the working tree at all. + this.WorkingVersion = context.IsHead ? context.VersionFile.GetWorkingCopyVersion() : this.CommittedVersion; if (overrideVersionHeightOffset.HasValue) { - if (committedVersion != null) + if (this.CommittedVersion is object) { - committedVersion.VersionHeightOffset = overrideVersionHeightOffset.Value; + this.CommittedVersion.VersionHeightOffset = overrideVersionHeightOffset.Value; } - if (workingVersion != null) + if (this.WorkingVersion is object) { - workingVersion.VersionHeightOffset = overrideVersionHeightOffset.Value; + this.WorkingVersion.VersionHeightOffset = overrideVersionHeightOffset.Value; } } - this.VersionOptions = committedVersion ?? workingVersion; + this.BuildingRef = cloudBuild?.BuildingTag ?? cloudBuild?.BuildingBranch ?? context.HeadCanonicalName; + try + { + this.VersionHeight = context.CalculateVersionHeight(this.CommittedVersion, this.WorkingVersion); + } + catch (GitException ex) when (context.IsShallow && ex.ErrorCode == GitException.ErrorCodes.ObjectNotFound) + { + // Our managed git implementation throws this on shallow clones. + throw ThrowShallowClone(ex); + } + catch (InvalidOperationException ex) when (context.IsShallow && (ex.InnerException is NullReferenceException || ex.InnerException is LibGit2Sharp.NotFoundException)) + { + // Libgit2 throws this on shallow clones. + throw ThrowShallowClone(ex); + } + + static Exception ThrowShallowClone(Exception inner) => throw new GitException("Shallow clone lacks the objects required to calculate version height. Use full clones or clones with a history at least as deep as the last version height resetting change.", inner) { iSShallowClone = true, ErrorCode = GitException.ErrorCodes.ObjectNotFound }; - this.GitCommitId = commit?.Id.Sha ?? cloudBuild?.GitCommitId ?? null; - this.GitCommitDate = commit?.Author.When; - this.VersionHeight = CalculateVersionHeight(relativeRepoProjectDirectory, commit, committedVersion, workingVersion); - this.BuildingRef = cloudBuild?.BuildingTag ?? cloudBuild?.BuildingBranch ?? repo?.Head.CanonicalName; + this.VersionOptions = this.CommittedVersion ?? this.WorkingVersion; + this.Version = this.VersionOptions?.Version?.Version ?? Version0; // Override the typedVersion with the special build number and revision components, when available. - if (repo != null) + if (context.IsRepository) { - this.Version = GetIdAsVersion(commit, committedVersion, workingVersion, this.VersionHeight); - } - else - { - this.Version = this.VersionOptions?.Version.Version ?? Version0; + this.Version = context.GetIdAsVersion(this.CommittedVersion, this.WorkingVersion, this.VersionHeight); } + this.CloudBuildNumberOptions = this.VersionOptions?.CloudBuild?.BuildNumberOrDefault ?? VersionOptions.CloudBuildNumberOptions.DefaultInstance; + // get the commit id abbreviation only if the commit id is set if (!string.IsNullOrEmpty(this.GitCommitId)) { var gitCommitIdShortFixedLength = this.VersionOptions?.GitCommitIdShortFixedLength ?? VersionOptions.DefaultGitCommitIdShortFixedLength; var gitCommitIdShortAutoMinimum = this.VersionOptions?.GitCommitIdShortAutoMinimum ?? 0; - // get it from the git repository if there is a repository present and it is enabled - if (repo != null && gitCommitIdShortAutoMinimum > 0) - { - this.GitCommitIdShort = repo.ObjectDatabase.ShortenObjectId(commit, gitCommitIdShortAutoMinimum); - } - else - { - this.GitCommitIdShort = this.GitCommitId.Substring(0, gitCommitIdShortFixedLength); - } - } - - this.VersionHeightOffset = this.VersionOptions?.VersionHeightOffsetOrDefault ?? 0; - this.PrereleaseVersion = this.ReplaceMacros(this.VersionOptions?.Version?.Prerelease ?? string.Empty); - - this.CloudBuildNumberOptions = this.VersionOptions?.CloudBuild?.BuildNumberOrDefault ?? VersionOptions.CloudBuildNumberOptions.DefaultInstance; + // Get it from the git repository if there is a repository present and it is enabled. + this.GitCommitIdShort = this.GitCommitId is object && gitCommitIdShortAutoMinimum > 0 + ? this.context.GetShortUniqueCommitId(gitCommitIdShortAutoMinimum) + : this.GitCommitId!.Substring(0, gitCommitIdShortFixedLength); + } if (!string.IsNullOrEmpty(this.BuildingRef) && this.VersionOptions?.PublicReleaseRefSpec?.Count > 0) { @@ -127,6 +103,16 @@ public VersionOracle(string projectDirectory, LibGit2Sharp.Repository repo, LibG } } + /// + /// Gets the that were deserialized from the contextual commit, if any. + /// + protected VersionOptions? CommittedVersion { get; } + + /// + /// Gets the that were deserialized from the working tree, if any. + /// + protected VersionOptions? WorkingVersion { get; } + /// /// Gets the BuildNumber to set the cloud build to (if applicable). /// @@ -159,9 +145,9 @@ public IEnumerable BuildMetadataWithCommitId { get { - if (!string.IsNullOrEmpty(this.GitCommitId)) + if (!string.IsNullOrEmpty(this.GitCommitIdShort)) { - yield return this.GitCommitIdShort; + yield return this.GitCommitIdShort!; } foreach (string identifier in this.BuildMetadata) @@ -174,12 +160,12 @@ public IEnumerable BuildMetadataWithCommitId /// /// Gets a value indicating whether a version.json or version.txt file was found. /// - public bool VersionFileFound => this.VersionOptions != null; + public bool VersionFileFound => this.VersionOptions is object; /// /// Gets the version options used to initialize this instance. /// - public VersionOptions VersionOptions { get; } + public VersionOptions? VersionOptions { get; } /// /// Gets the version string to use for the . @@ -206,12 +192,12 @@ public IEnumerable BuildMetadataWithCommitId /// /// Gets the prerelease version information, including a leading hyphen. /// - public string PrereleaseVersion { get; } + public string PrereleaseVersion => this.ReplaceMacros(this.VersionOptions?.Version?.Prerelease ?? string.Empty); /// /// Gets the prerelease version information, omitting the leading hyphen, if any. /// - public string PrereleaseVersionNoLeadingHyphen => this.PrereleaseVersion?.TrimStart('-'); + public string? PrereleaseVersionNoLeadingHyphen => this.PrereleaseVersion?.TrimStart('-'); /// /// Gets the version information without a Revision component. @@ -251,41 +237,41 @@ public IEnumerable BuildMetadataWithCommitId /// /// Gets the Git revision control commit id for HEAD (the current source code version). /// - public string GitCommitId { get; } + public string? GitCommitId => this.context.GitCommitId ?? this.cloudBuild?.GitCommitId; /// /// Gets the first several characters of the Git revision control commit id for HEAD (the current source code version). /// - public string GitCommitIdShort { get; } + public string? GitCommitIdShort { get; } /// /// Gets the Git revision control commit date for HEAD (the current source code version). /// - public DateTimeOffset? GitCommitDate { get; } + public DateTimeOffset? GitCommitDate => this.context.GitCommitDate; /// /// Gets the number of commits in the longest single path between /// the specified commit and the most distant ancestor (inclusive) /// that set the version to the value at HEAD. /// - public int VersionHeight { get; } + public int VersionHeight { get; protected set; } /// /// The offset to add to the /// when calculating the integer to use as the /// or elsewhere that the {height} macro is used. /// - public int VersionHeightOffset { get; } + public int VersionHeightOffset => this.VersionOptions?.VersionHeightOffsetOrDefault ?? 0; /// /// Gets the ref (branch or tag) being built. /// - public string BuildingRef { get; } + public string? BuildingRef { get; protected set; } /// /// Gets the version for this project, with up to 4 components. /// - public Version Version { get; } + public Version Version { get; protected set; } /// /// Gets a value indicating whether to set all cloud build variables prefaced with "NBGV_". @@ -311,9 +297,9 @@ public IDictionary CloudBuildAllVars if (property.GetCustomAttribute() == null) { var value = property.GetValue(this); - if (value != null) + if (value is object) { - variables.Add($"NBGV_{property.Name}", value.ToString()); + variables.Add($"NBGV_{property.Name}", value.ToString() ?? string.Empty); } } } @@ -395,6 +381,11 @@ public IDictionary CloudBuildVersionVars /// public int SemVer1NumericIdentifierPadding => this.VersionOptions?.SemVer1NumericIdentifierPaddingOrDefault ?? 4; + /// + /// Gets or sets the . + /// + protected VersionOptions.CloudBuildNumberOptions CloudBuildNumberOptions { get; set; } + /// /// Gets the build metadata, compliant to the NuGet-compatible subset of SemVer 1.0. /// @@ -443,17 +434,12 @@ public IDictionary CloudBuildVersionVars /// private string GitCommitIdShortForNonPublicPrereleaseTag => (string.IsNullOrEmpty(this.PrereleaseVersion) ? "-" : ".") + (this.VersionOptions?.GitCommitIdPrefix ?? "g") + this.GitCommitIdShort; - private VersionOptions.CloudBuildNumberOptions CloudBuildNumberOptions { get; } - private int VersionHeightWithOffset => this.VersionHeight + this.VersionHeightOffset; private static string FormatBuildMetadata(IEnumerable identifiers) => (identifiers?.Any() ?? false) ? "+" + string.Join(".", identifiers) : string.Empty; - private static string FormatBuildMetadataSemVerV1(IEnumerable identifiers) => - (identifiers?.Any() ?? false) ? "-" + string.Join("-", identifiers) : string.Empty; - - private static Version GetAssemblyVersion(Version version, VersionOptions versionOptions) + private static Version GetAssemblyVersion(Version version, VersionOptions? versionOptions) { // If there is no repo, "version" could have uninitialized components (-1). version = version.EnsureNonNegativeComponents(); @@ -474,44 +460,7 @@ private static Version GetAssemblyVersion(Version version, VersionOptions versio /// /// The prerelease or build metadata. /// The specified string, with macros substituted for actual values. - private string ReplaceMacros(string prereleaseOrBuildMetadata) => prereleaseOrBuildMetadata?.Replace(VersionOptions.VersionHeightPlaceholder, this.VersionHeightWithOffset.ToString(CultureInfo.InvariantCulture)); - - private static int CalculateVersionHeight(string relativeRepoProjectDirectory, LibGit2Sharp.Commit headCommit, VersionOptions committedVersion, VersionOptions workingVersion) - { - var headCommitVersion = committedVersion?.Version ?? SemVer0; - - if (IsVersionFileChangedInWorkingTree(committedVersion, workingVersion)) - { - var workingCopyVersion = workingVersion?.Version?.Version; - - if (workingCopyVersion == null || !workingCopyVersion.Equals(headCommitVersion)) - { - // The working copy has changed the major.minor version. - // So by definition the version height is 0, since no commit represents it yet. - return 0; - } - } - - return headCommit?.GetVersionHeight(relativeRepoProjectDirectory) ?? 0; - } - - private static Version GetIdAsVersion(LibGit2Sharp.Commit headCommit, VersionOptions committedVersion, VersionOptions workingVersion, int versionHeight) - { - var version = IsVersionFileChangedInWorkingTree(committedVersion, workingVersion) ? workingVersion : committedVersion; - - return headCommit.GetIdAsVersionHelper(version, versionHeight); - } - - private static bool IsVersionFileChangedInWorkingTree(VersionOptions committedVersion, VersionOptions workingVersion) - { - if (workingVersion != null) - { - return !EqualityComparer.Default.Equals(workingVersion, committedVersion); - } - - // A missing working version is a change only if it was previously commited. - return committedVersion != null; - } + private string ReplaceMacros(string prereleaseOrBuildMetadata) => prereleaseOrBuildMetadata.Replace(VersionOptions.VersionHeightPlaceholder, this.VersionHeightWithOffset.ToString(CultureInfo.InvariantCulture)); [AttributeUsage(AttributeTargets.Property)] private class IgnoreAttribute : Attribute diff --git a/src/Nerdbank.GitVersioning.Tasks/GetBuildVersion.cs b/src/Nerdbank.GitVersioning.Tasks/GetBuildVersion.cs index 32945da2..2a9c00d4 100644 --- a/src/Nerdbank.GitVersioning.Tasks/GetBuildVersion.cs +++ b/src/Nerdbank.GitVersioning.Tasks/GetBuildVersion.cs @@ -61,6 +61,11 @@ public GetBuildVersion() /// public int OverrideBuildNumberOffset { get; set; } = int.MaxValue; + /// + /// Gets or sets the git engine to use. + /// + public string GitEngine { get; set; } + /// /// Gets or sets the path to the folder that contains the NB.GV .targets file. /// @@ -195,7 +200,7 @@ public GetBuildVersion() [Output] public ITaskItem[] CloudBuildVersionVars { get; private set; } - protected override string UnmanagedDllDirectory => GitExtensions.FindLibGit2NativeBinaries(this.TargetsPath); + protected override string UnmanagedDllDirectory => LibGit2.LibGit2GitExtensions.FindLibGit2NativeBinaries(this.TargetsPath); protected override bool ExecuteInner() { @@ -211,9 +216,22 @@ protected override bool ExecuteInner() "Path must not use ..\\"); } + bool useLibGit2 = false; + if (!string.IsNullOrWhiteSpace(this.GitEngine)) + { + useLibGit2 = + this.GitEngine == "Managed" ? false : + this.GitEngine == "LibGit2" ? true : + throw new ArgumentException("GitEngine property must be set to either \"Managed\" or \"LibGit2\" or left empty."); + } + var cloudBuild = CloudBuild.Active; var overrideBuildNumberOffset = (this.OverrideBuildNumberOffset == int.MaxValue) ? (int?)null : this.OverrideBuildNumberOffset; - var oracle = VersionOracle.Create(this.ProjectDirectory, this.GitRepoRoot, cloudBuild, overrideBuildNumberOffset, this.ProjectPathRelativeToGitRepoRoot); + string projectDirectory = this.ProjectPathRelativeToGitRepoRoot is object && this.GitRepoRoot is object + ? Path.Combine(this.GitRepoRoot, this.ProjectPathRelativeToGitRepoRoot) + : this.ProjectDirectory; + using var context = GitContext.Create(projectDirectory, writable: useLibGit2); + var oracle = new VersionOracle(context, cloudBuild, overrideBuildNumberOffset); if (!string.IsNullOrEmpty(this.DefaultPublicRelease)) { oracle.PublicRelease = string.Equals(this.DefaultPublicRelease, "true", StringComparison.OrdinalIgnoreCase); diff --git a/src/Nerdbank.GitVersioning.Tasks/GitLoaderContext.cs b/src/Nerdbank.GitVersioning.Tasks/GitLoaderContext.cs index 5f2b68e4..67df6897 100644 --- a/src/Nerdbank.GitVersioning.Tasks/GitLoaderContext.cs +++ b/src/Nerdbank.GitVersioning.Tasks/GitLoaderContext.cs @@ -15,19 +15,13 @@ public class GitLoaderContext : AssemblyLoadContext { public static readonly GitLoaderContext Instance = new GitLoaderContext(); - // When invoked as a MSBuild task, the native libraries will be at - // ../runtimes. When invoked from the nbgv CLI, the libraries - // will be at ./runtimes. - // This property allows code which consumes GitLoaderContext to - // differentiate between these different locations. - // In the case of the nbgv CLI, the value is set in Program.Main() - public static string RuntimePath = "../runtimes"; + public const string RuntimePath = "./runtimes"; protected override Assembly Load(AssemblyName assemblyName) { var path = Path.Combine(Path.GetDirectoryName(typeof(GitLoaderContext).Assembly.Location), assemblyName.Name + ".dll"); return File.Exists(path) - ? LoadFromAssemblyPath(path) + ? this.LoadFromAssemblyPath(path) : Default.LoadFromAssemblyName(assemblyName); } @@ -52,7 +46,7 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) nativeLibraryPath = Path.Combine(directory, "lib" + unmanagedDllName); } - modulePtr = LoadUnmanagedDllFromPath(nativeLibraryPath); + modulePtr = this.LoadUnmanagedDllFromPath(nativeLibraryPath); } return (modulePtr != IntPtr.Zero) ? modulePtr : base.LoadUnmanagedDll(unmanagedDllName); @@ -61,7 +55,17 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) internal static string GetNativeLibraryDirectory() { var dir = Path.GetDirectoryName(typeof(GitLoaderContext).Assembly.Location); - return Path.Combine(dir, RuntimePath, RuntimeIdMap.GetNativeLibraryDirectoryName(RuntimeEnvironment.GetRuntimeIdentifier()), "native"); + + // When invoked as a MSBuild task, the native libraries will be at + // ../runtimes. When invoked from the nbgv CLI, the libraries + // will be at ./runtimes. + string runtimePath = RuntimePath; + if (!Directory.Exists(Path.Combine(dir, runtimePath))) + { + runtimePath = "." + runtimePath; + } + + return Path.Combine(dir, runtimePath, RuntimeIdMap.GetNativeLibraryDirectoryName(RuntimeEnvironment.GetRuntimeIdentifier()), "native"); } private static string GetNativeLibraryExtension() diff --git a/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.Tasks.csproj b/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.Tasks.csproj index 1c9f00f0..d6d88eaa 100644 --- a/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.Tasks.csproj +++ b/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.Tasks.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net461 + net461;netcoreapp2.1 true Nerdbank.GitVersioning.nuspec @@ -25,7 +25,7 @@ - MSBuildCore\ + MSBuildCore\ MSBuildFull\ @@ -36,6 +36,8 @@ $(OutputPath)Microsoft.DotNet.PlatformAbstractions.dll; $(OutputPath)Nerdbank.GitVersioning.*dll; $(OutputPath)Newtonsoft.Json.dll; + $(OutputPath)System.Text.Json.dll; + $(OutputPath)System.Runtime.CompilerServices.Unsafe.dll; $(OutputPath)Validation.dll; "> build\$(BuildSubDir) @@ -56,10 +58,6 @@ true buildCrossTargeting\ - - true - tools\ - true readme.txt @@ -76,7 +74,7 @@ - + @@ -84,7 +82,7 @@ - + diff --git a/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.nuspec b/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.nuspec index 90fb7cbb..cc778937 100644 --- a/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.nuspec +++ b/src/Nerdbank.GitVersioning.Tasks/Nerdbank.GitVersioning.nuspec @@ -20,10 +20,13 @@ IMPORTANT: The 3.x release may produce a different version height than prior maj - + + + + @@ -35,21 +38,20 @@ IMPORTANT: The 3.x release may produce a different version height than prior maj - - - - - - + + + + + + + + + - - - - diff --git a/src/Nerdbank.GitVersioning.Tasks/build/Nerdbank.GitVersioning.Inner.targets b/src/Nerdbank.GitVersioning.Tasks/build/Nerdbank.GitVersioning.Inner.targets index 96448fa7..a1a5a4d9 100644 --- a/src/Nerdbank.GitVersioning.Tasks/build/Nerdbank.GitVersioning.Inner.targets +++ b/src/Nerdbank.GitVersioning.Tasks/build/Nerdbank.GitVersioning.Inner.targets @@ -22,7 +22,8 @@ GitRepoRoot="$(GitRepoRoot)" ProjectPathRelativeToGitRepoRoot="$(ProjectPathRelativeToGitRepoRoot)" OverrideBuildNumberOffset="$(OverrideBuildNumberOffset)" - TargetsPath="$(MSBuildThisFileDirectory)"> + TargetsPath="$(MSBuildThisFileDirectory)" + GitEngine="$(NBGV_GitEngine)"> diff --git a/src/Nerdbank.GitVersioning.Tasks/tools/Create-VersionFile.ps1 b/src/Nerdbank.GitVersioning.Tasks/tools/Create-VersionFile.ps1 deleted file mode 100644 index 3fc84f88..00000000 --- a/src/Nerdbank.GitVersioning.Tasks/tools/Create-VersionFile.ps1 +++ /dev/null @@ -1,90 +0,0 @@ -<# -.SYNOPSIS -Generates a version.json file if one does not exist. -.DESCRIPTION -When creating version.json, AssemblyInfo.cs is loaded and the Major.Minor from the AssemblyVersion attribute is -used to seed the version number. Then, those Assembly attributes are removed from AssemblyInfo.cs. This cmdlet -returns the path to the generated file, or null if the file was not created (it already existed, or the cmdlet -was being executed with -WhatIf). -.PARAMETER ProjectDirectory -The directory of the project which is adding versioning logic with Nerdbank.GitVersioning. -.PARAMETER OutputDirectory -The directory where version.json should be generated. Defaults to the project directory if not specified. -This should either be the project directory, or in a parent directory of the project inside the repo. -#> -[CmdletBinding(SupportsShouldProcess=$true)] -Param( - [Parameter()] - [string]$ProjectDirectory=".", - [Parameter()] - [string]$OutputDirectory=$null -) - -$ProjectDirectory = Resolve-Path $ProjectDirectory -if (!$OutputDirectory) -{ - $OutputDirectory = $ProjectDirectory -} - -$versionFileFound = $false -$SearchDirectory = $OutputDirectory -while (-not $versionFileFound -and $SearchDirectory) { - $versionTxtPath = Join-Path $SearchDirectory "version.txt" - $versionJsonPath = Join-Path $SearchDirectory "version.json" - $versionFileFound = (Test-Path $versionTxtPath) -or (Test-Path $versionJsonPath) - $SearchDirectory = Split-Path $SearchDirectory -} - -if (-not $versionFileFound) -{ - $versionJsonPath = Join-Path $OutputDirectory "version.json" - - # The version file doesn't exist, which means this package is being installed for the first time. - # 1) Load up the AssemblyInfo.cs file and grab the existing version declarations. - # 2) Generate the version.txt with the version seeded from AssemblyInfo.cs - # 3) Delete the version-related attributes in AssemblyInfo.cs - - $propertiesDirectory = Join-Path $ProjectDirectory "Properties" - $assemblyInfo = Join-Path $propertiesDirectory "AssemblyInfo.cs" - $version = $null - if (Test-Path $assemblyInfo) - { - $fixedLines = (Get-Content $assemblyInfo) | ForEach-Object { - if ($_ -match "^\w*\[assembly: AssemblyVersion\(""([0-9]+.[0-9]+|\*)(?:.(?:[0-9]+|\*)){0,2}""\)\]$") - { - # Grab the Major.Minor out of this file which will be injected into the version.txt - $version = $matches[1] - } - - # Remove attributes related to assembly versioning since those are generated on the fly during the build - $_ -replace "^\[assembly: Assembly(?:File|Informational|)Version\(""[0-9]+(?:.(?:[0-9]+|\*)){1,3}""\)\]$" - } - - if ($PSCmdlet.ShouldProcess($assemblyInfo, "Removing assembly attributes")) - { - $fixedLines | Set-Content $assemblyInfo -Encoding UTF8 - } - - if ($version) - { - if ($PSCmdlet.ShouldProcess($versionJsonPath, "Writing version.json file")) - { - "{ - `"`$schema`": `"https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json`", - `"version`": `"$version`" -}" | Set-Content $versionJsonPath - $versionJsonPath - } - } - else - { - # This is not a warning because the user is probably already consuming version.json from a parent directory as part of - # a solution- or repo-level versioning scheme. - Write-Verbose "Could not find an AssemblyVersion attribute in file '$assemblyInfo'. Skipping version.json generation." - } - } - else - { - Write-Warning "Could not find an AssemblyInfo.cs file at '$assemblyInfo'. Skipping version.json generation." - } -} diff --git a/src/Nerdbank.GitVersioning.Tasks/tools/Get-CommitId.ps1 b/src/Nerdbank.GitVersioning.Tasks/tools/Get-CommitId.ps1 deleted file mode 100644 index 1e065bc8..00000000 --- a/src/Nerdbank.GitVersioning.Tasks/tools/Get-CommitId.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -<# -.SYNOPSIS -Finds the git commit ID that was built to produce some specific version of an assembly. -.DESCRIPTION -TODO -.PARAMETER GitPath -The path to the git repo root. If omitted, the current working directory is assumed. -.PARAMETER AssemblyPath -Path to the assembly to read the version from. -.PARAMETER Version -The major.minor.build.revision version string or semver-compliant version read from the assembly. -.PARAMETER ProjectDirectory -The directory of the project that built the assembly, within the git repo. -#> -[CmdletBinding(SupportsShouldProcess)] -Param( - [Parameter()] - [string]$ProjectDirectory=".", - [Parameter()] - [string]$AssemblyPath, - [Parameter(Mandatory=$true, Position=0)] - [string]$Version -) - -if (-not (Test-Path variable:global:DependencyBasePath) -or !$DependencyBasePath) { $DependencyBasePath = "$PSScriptRoot\..\build\MSBuildFull" } -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\Validation.dll")) -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\NerdBank.GitVersioning.dll")) -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\LibGit2Sharp.dll")) -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\Newtonsoft.Json.dll")) -[Nerdbank.GitVersioning.GitExtensions]::HelpFindLibGit2NativeBinaries($DependencyBasePath) - -$ProjectDirectory = (Resolve-Path $ProjectDirectory).ProviderPath -$GitPath = $ProjectDirectory -while (!(Test-Path "$GitPath\.git") -and $GitPath.Length -gt 0) { - $GitPath = Split-Path $GitPath -} - -if ($GitPath -eq '') { - Write-Error "Unable to find git repo in $ProjectDirectory." - return 1 -} - -$RepoRelativeProjectDirectory = $ProjectDirectory.Substring($GitPath.Length).TrimStart([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) - -$TypedVersion = New-Object Version($Version) - -$repo = New-Object LibGit2Sharp.Repository($GitPath) -try { - $commit = [NerdBank.GitVersioning.GitExtensions]::GetCommitsFromVersion($repo, $TypedVersion, $RepoRelativeProjectDirectory) - $commit.Id.Sha -} finally { - $repo.Dispose() -} diff --git a/src/Nerdbank.GitVersioning.Tasks/tools/Get-Version.ps1 b/src/Nerdbank.GitVersioning.Tasks/tools/Get-Version.ps1 deleted file mode 100644 index b3a662a9..00000000 --- a/src/Nerdbank.GitVersioning.Tasks/tools/Get-Version.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -<# -.SYNOPSIS -Finds the git commit ID that was built to produce some specific version of an assembly. -.PARAMETER ProjectDirectory -The directory of the project that built the assembly, within the git repo. -#> -[CmdletBinding(SupportsShouldProcess)] -Param( - [Parameter()] - [string]$ProjectDirectory="." -) - -if (-not (Test-Path variable:global:DependencyBasePath) -or !$DependencyBasePath) { $DependencyBasePath = "$PSScriptRoot\..\build\MSBuildFull" } -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\Validation.dll")) -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\NerdBank.GitVersioning.dll")) -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\LibGit2Sharp.dll")) -$null = [Reflection.Assembly]::LoadFile((Resolve-Path "$DependencyBasePath\Newtonsoft.Json.dll")) - -$ProjectDirectory = (Resolve-Path $ProjectDirectory).ProviderPath - -try { - [Nerdbank.GitVersioning.GitExtensions]::HelpFindLibGit2NativeBinaries($DependencyBasePath) - $CloudBuild = [Nerdbank.GitVersioning.CloudBuild]::Active - $VersionOracle = [Nerdbank.GitVersioning.VersionOracle]::Create($ProjectDirectory, $null, $CloudBuild) - $VersionOracle -} -finally { - # the try is here so Powershell aborts after first failed step. -} diff --git a/src/Nerdbank.GitVersioning.Tasks/tools/Install.ps1 b/src/Nerdbank.GitVersioning.Tasks/tools/Install.ps1 deleted file mode 100644 index f606deda..00000000 --- a/src/Nerdbank.GitVersioning.Tasks/tools/Install.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -param($installPath, $toolsPath, $package, $project) - -$projectDirectory = Split-Path $project.FileName -$generatedFile = .(Join-Path $toolsPath Create-VersionFile.ps1) -ProjectDirectory $projectDirectory - -if ($generatedFile) -{ - # Add version.json to the project so it shows up in VS. - $result = $project.ProjectItems.AddFromFile($generatedFile) - - # By default, items get added with ItemType=Content, which could cause version.txt to be included in the build's output. - # Set the type to None so that it is ignored. - $result.Properties["ItemType"].Value = "None" - - $project.Save() -} diff --git a/src/Nerdbank.GitVersioning.sln b/src/Nerdbank.GitVersioning.sln index 0b49b21a..66c96ce6 100644 --- a/src/Nerdbank.GitVersioning.sln +++ b/src/Nerdbank.GitVersioning.sln @@ -5,15 +5,16 @@ VisualStudioVersion = 16.0.28404.58 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4BD1A7CD-6F52-4F5A-825B-50E4D8C3ECFF}" ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig + ..\.editorconfig = ..\.editorconfig ..\.gitignore = ..\.gitignore ..\azure-pipelines.yml = ..\azure-pipelines.yml ..\build.ps1 = ..\build.ps1 Directory.Build.props = Directory.Build.props + ..\global.json = ..\global.json ..\init.ps1 = ..\init.ps1 nuget.config = nuget.config ..\README.md = ..\README.md - version.json = version.json + ..\version.json = ..\version.json EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NerdBank.GitVersioning.Tests", "NerdBank.GitVersioning.Tests\NerdBank.GitVersioning.Tests.csproj", "{C54F9EC8-FDA7-4D22-BCB2-7D97523BD91E}" @@ -28,6 +29,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "nbgv", "nbgv\nbgv.csproj", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cake.GitVersioning", "Cake.GitVersioning\Cake.GitVersioning.csproj", "{1F267A97-DFE3-4166-83B1-9D236B7A09BD}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nerdbank.GitVersioning.Benchmarks", "NerdBank.GitVersioning.Benchmarks\Nerdbank.GitVersioning.Benchmarks.csproj", "{B0B7955D-E51F-4091-BF7F-55D07D381D15}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -56,6 +59,10 @@ Global {1F267A97-DFE3-4166-83B1-9D236B7A09BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {1F267A97-DFE3-4166-83B1-9D236B7A09BD}.Release|Any CPU.ActiveCfg = Release|Any CPU {1F267A97-DFE3-4166-83B1-9D236B7A09BD}.Release|Any CPU.Build.0 = Release|Any CPU + {B0B7955D-E51F-4091-BF7F-55D07D381D15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0B7955D-E51F-4091-BF7F-55D07D381D15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0B7955D-E51F-4091-BF7F-55D07D381D15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0B7955D-E51F-4091-BF7F-55D07D381D15}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index f3658fd8..c35ccf80 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -8,6 +8,7 @@ namespace Nerdbank.GitVersioning.Tool using System.Reflection; using System.Threading; using System.Threading.Tasks; + using Nerdbank.GitVersioning.LibGit2; using Newtonsoft.Json; using NuGet.Common; using NuGet.Configuration; @@ -55,14 +56,16 @@ private enum ExitCodes InvalidVersionIncrement, InvalidNuGetPackageSource, PackageIdNotFound, + ShallowClone, + InternalError, } private static ExitCodes exitCode; + private static bool AlwaysUseLibGit2 => string.Equals(Environment.GetEnvironmentVariable("NBGV_GitEngine"), "LibGit2", StringComparison.Ordinal); + public static int Main(string[] args) { - GitLoaderContext.RuntimePath = "./runtimes"; - string thisAssemblyPath = new Uri(typeof(Program).GetTypeInfo().Assembly.CodeBase).LocalPath; Assembly inContextAssembly = GitLoaderContext.Instance.LoadFromAssemblyPath(thisAssemblyPath); @@ -151,33 +154,45 @@ private static int MainInner(string[] args) } }); - if (install.IsActive) - { - exitCode = OnInstallCommand(versionJsonRoot, version, sources); - } - else if (getVersion.IsActive) - { - exitCode = OnGetVersionCommand(projectPath, buildMetadata, format, singleVariable, version); - } - else if (setVersion.IsActive) - { - exitCode = OnSetVersionCommand(projectPath, version); - } - else if (tag.IsActive) - { - exitCode = OnTagCommand(projectPath, version); - } - else if (getCommits.IsActive) - { - exitCode = OnGetCommitsCommand(projectPath, version, quiet); - } - else if (cloud.IsActive) + try { - exitCode = OnCloudCommand(projectPath, buildMetadata, version, cisystem, cloudBuildAllVars, cloudBuildCommonVars, cloudVariables); + if (install.IsActive) + { + exitCode = OnInstallCommand(versionJsonRoot, version, sources); + } + else if (getVersion.IsActive) + { + exitCode = OnGetVersionCommand(projectPath, buildMetadata, format, singleVariable, version); + } + else if (setVersion.IsActive) + { + exitCode = OnSetVersionCommand(projectPath, version); + } + else if (tag.IsActive) + { + exitCode = OnTagCommand(projectPath, version); + } + else if (getCommits.IsActive) + { + exitCode = OnGetCommitsCommand(projectPath, version, quiet); + } + else if (cloud.IsActive) + { + exitCode = OnCloudCommand(projectPath, buildMetadata, version, cisystem, cloudBuildAllVars, cloudBuildCommonVars, cloudVariables); + } + else if (prepareRelease.IsActive) + { + exitCode = OnPrepareReleaseCommand(projectPath, releasePreReleaseTag, releaseNextVersion, releaseVersionIncrement, format); + } } - else if (prepareRelease.IsActive) + catch (GitException ex) { - exitCode = OnPrepareReleaseCommand(projectPath, releasePreReleaseTag, releaseNextVersion, releaseVersionIncrement, format); + Console.Error.WriteLine($"ERROR: {ex.Message}"); + exitCode = ex.ErrorCode switch + { + GitException.ErrorCodes.ObjectNotFound when ex.iSShallowClone => ExitCodes.ShallowClone, + _ => ExitCodes.InternalError, + }; } return (int)exitCode; @@ -214,8 +229,8 @@ private static ExitCodes OnInstallCommand(string versionJsonRoot, string version return ExitCodes.NoGitRepo; } - var repository = GitExtensions.OpenGitRepo(searchPath); - if (repository == null) + using var context = GitContext.Create(searchPath, writable: true); + if (!context.IsRepository) { Console.Error.WriteLine("No git repo found at or above: \"{0}\"", searchPath); return ExitCodes.NoGitRepo; @@ -223,10 +238,10 @@ private static ExitCodes OnInstallCommand(string versionJsonRoot, string version if (string.IsNullOrEmpty(versionJsonRoot)) { - versionJsonRoot = repository.Info.WorkingDirectory; + versionJsonRoot = context.WorkingTreePath; } - var existingOptions = VersionFile.GetVersion(versionJsonRoot); + var existingOptions = context.VersionFile.GetVersion(); if (existingOptions != null) { if (!string.IsNullOrEmpty(version)) @@ -240,8 +255,8 @@ private static ExitCodes OnInstallCommand(string versionJsonRoot, string version } else { - string versionJsonPath = VersionFile.SetVersion(versionJsonRoot, options); - LibGit2Sharp.Commands.Stage(repository, versionJsonPath); + string versionJsonPath = context.VersionFile.SetVersion(versionJsonRoot, options); + context.Stage(versionJsonPath); } // Create/update the Directory.Build.props file in the directory of the version.json file to add the NB.GV package. @@ -291,7 +306,7 @@ private static ExitCodes OnInstallCommand(string versionJsonRoot, string version propsFile.Save(directoryBuildPropsPath); } - LibGit2Sharp.Commands.Stage(repository, directoryBuildPropsPath); + context.Stage(directoryBuildPropsPath); return ExitCodes.OK; } @@ -310,28 +325,20 @@ private static ExitCodes OnGetVersionCommand(string projectPath, IReadOnlyList 1) { - PrintCommits(false, searchPath, repository, candidateCommits, includeOptions: true); + PrintCommits(false, context, candidateCommits, includeOptions: true); int selection; do { Console.Write("Enter selection: "); } while (!int.TryParse(Console.ReadLine(), out selection) || selection > candidateCommits.Count || selection < 1); - commit = candidateCommits[selection - 1]; + context.TrySelectCommit(candidateCommits[selection - 1].Sha); } else { - commit = candidateCommits.Single(); + context.TrySelectCommit(candidateCommits.Single().Sha); } } - var oracle = new VersionOracle(searchPath, repository, commit, CloudBuild.Active); + var oracle = new VersionOracle(context, CloudBuild.Active); if (!oracle.VersionFileFound) { - Console.Error.WriteLine("No version.json file found in or above \"{0}\" in commit {1}.", searchPath, commit.Sha); + Console.Error.WriteLine("No version.json file found in or above \"{0}\" in commit {1}.", searchPath, context.GitCommitId); return ExitCodes.NoVersionJsonFound; } @@ -496,17 +496,17 @@ private static ExitCodes OnTagCommand(string projectPath, string versionOrRef) string tagName = $"v{oracle.SemVer2}"; try { - repository.Tags.Add(tagName, commit); + context.ApplyTag(tagName); } catch (LibGit2Sharp.NameConflictException) { var taggedCommit = repository.Tags[tagName].Target as LibGit2Sharp.Commit; - bool correctTag = taggedCommit?.Sha == commit.Sha; - Console.Error.WriteLine("The tag {0} is already defined ({1}).", tagName, correctTag ? "to the right commit" : $"expected {commit.Sha} but was on {taggedCommit.Sha}"); + bool correctTag = taggedCommit?.Sha == context.GitCommitId; + Console.Error.WriteLine("The tag {0} is already defined ({1}).", tagName, correctTag ? "to the right commit" : $"expected {context.GitCommitId} but was on {taggedCommit.Sha}"); return correctTag ? ExitCodes.OK : ExitCodes.TagConflict; } - Console.WriteLine("{0} tag created at {1}.", tagName, commit.Sha); + Console.WriteLine("{0} tag created at {1}.", tagName, context.GitCommitId); Console.WriteLine("Remember to push to a remote: git push origin {0}", tagName); return ExitCodes.OK; @@ -522,16 +522,15 @@ private static ExitCodes OnGetCommitsCommand(string projectPath, string version, string searchPath = GetSpecifiedOrCurrentDirectoryPath(projectPath); - var repository = GitExtensions.OpenGitRepo(searchPath); - if (repository == null) + using var context = (LibGit2Context)GitContext.Create(searchPath, writable: true); + if (!context.IsRepository) { Console.Error.WriteLine("No git repo found at or above: \"{0}\"", searchPath); return ExitCodes.NoGitRepo; } - string repoRelativeProjectDir = GetRepoRelativePath(searchPath, repository); - var candidateCommits = GitExtensions.GetCommitsFromVersion(repository, parsedVersion, repoRelativeProjectDir); - PrintCommits(quiet, searchPath, repository, candidateCommits); + var candidateCommits = LibGit2GitExtensions.GetCommitsFromVersion(context, parsedVersion); + PrintCommits(quiet, context, candidateCommits); return ExitCodes.OK; } @@ -558,7 +557,8 @@ private static ExitCodes OnCloudCommand(string projectPath, IReadOnlyList(); if (cloudBuildAllVars) @@ -772,7 +772,7 @@ private static string ShouldHaveTrailingDirectorySeparator(string path) return path + Path.DirectorySeparatorChar; } - private static void PrintCommits(bool quiet, string projectDirectory, LibGit2Sharp.Repository repository, IEnumerable candidateCommits, bool includeOptions = false) + private static void PrintCommits(bool quiet, GitContext context, IEnumerable candidateCommits, bool includeOptions = false) { int index = 1; foreach (var commit in candidateCommits) @@ -788,7 +788,8 @@ private static void PrintCommits(bool quiet, string projectDirectory, LibGit2Sha } else { - var oracle = new VersionOracle(projectDirectory, repository, commit, null); + Assumes.True(context.TrySelectCommit(commit.Sha)); + var oracle = new VersionOracle(context, null); Console.WriteLine($"{commit.Sha} {oracle.Version} {commit.MessageShort}"); } } diff --git a/src/nbgv/nbgv.csproj b/src/nbgv/nbgv.csproj index 888e7838..4cde3ab9 100644 --- a/src/nbgv/nbgv.csproj +++ b/src/nbgv/nbgv.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/nuget.config b/src/nuget.config index 5ee93301..ac72677f 100644 --- a/src/nuget.config +++ b/src/nuget.config @@ -7,5 +7,7 @@ + +