diff --git a/.github/workflows/_unit_tests.yml b/.github/workflows/_unit_tests.yml index bdc5205a6d..c038625545 100644 --- a/.github/workflows/_unit_tests.yml +++ b/.github/workflows/_unit_tests.yml @@ -16,7 +16,7 @@ permissions: jobs: unit_test: - name: ${{ matrix.os }} - net${{ matrix.dotnet_version }} + name: ${{ matrix.os }} - net${{ matrix.dotnet_version }} - ${{ matrix.git_backend }} permissions: id-token: write strategy: @@ -24,6 +24,9 @@ jobs: matrix: os: [ windows-2025-vs2026, ubuntu-24.04, macos-26 ] dotnet_version: ${{ fromJson(inputs.dotnet_versions) }} + # Run the suite against both Git backends until libgit2 is removed, + # see https://github.com/GitTools/GitVersion/issues/5031 + git_backend: [ libgit2, managed ] runs-on: ${{ matrix.os }} steps: @@ -41,6 +44,8 @@ jobs: - name: '[Unit Test]' uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + env: + GITVERSION_GIT_BACKEND: ${{ matrix.git_backend }} with: shell: pwsh timeout_minutes: 30 @@ -60,6 +65,7 @@ jobs: with: files: artifacts/test-results/**/results.xml report_type: 'test_results' + flags: ${{ matrix.git_backend }} use_oidc: true - name: Upload Coverage @@ -68,4 +74,5 @@ jobs: with: directory: artifacts/test-results report_type: 'coverage' + flags: ${{ matrix.git_backend }} use_oidc: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93236874e2..92c506d2a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ on: - main - 'support/*' - 'next/*' + # gate managed-git phase PRs with the full test matrix pre-merge (#5031) + - 'feature/managed-git' paths: - '**' - '!docs/**' diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 38d00f6146..c0da407e3e 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -16,6 +16,21 @@ GitVersion no longer ships native `osx-x64` artifacts. Apple Silicon (`osx-arm64 `CommitsSinceVersionSource` is no longer emitted in JSON output, build-agent environment variables, generated version-information files, or the MSBuild `GetVersion` task. It can no longer be used in custom format strings. Use `VersionSourceDistance` instead; it has the same value. +### Selectable Git backend (libgit2 vs. managed) + +GitVersion is migrating away from LibGit2Sharp and its native libgit2 binaries towards a managed implementation combined with the `git` CLI ([#5031](https://github.com/GitTools/GitVersion/issues/5031)). A single environment variable selects the backend: + +| Release | Default backend | Switch | +| ------- | --------------- | ------ | +| v7.0 | `libgit2` | `GITVERSION_GIT_BACKEND=managed` to opt in to the new backend | +| v7.1 | `managed` | `GITVERSION_GIT_BACKEND=libgit2` to fall back | +| later | `managed` | libgit2 backend removed | + +Behavioral notes when using the `managed` backend: + +* Mutating and network operations (repository normalization on CI build agents, dynamic repositories via `--url`, checkout, fetch) are performed by invoking the `git` executable, which must be available on the `PATH`. Plain version calculation on an already-prepared checkout does not require it. +* v7.0 behavior is unchanged unless you opt in. Please test the `managed` backend and report issues — the libgit2 backend will be removed once the new backend has proven itself over several releases. + ### Invalid label formatting is not ignored Previously bad label formatting config would be silently accepted. For example `{Branhc}` (when BranchName is misspelled) or even `{BranchName` (missing a closing brace). This is not ignored now and exceptions will be thrown if formatting problems exist in the label config. This brings it into line with how assembly string formatting is treated. diff --git a/build/parity-corpus.md b/build/parity-corpus.md new file mode 100644 index 0000000000..0d308fc44f --- /dev/null +++ b/build/parity-corpus.md @@ -0,0 +1,51 @@ +# parity-corpus.ps1 — real-world corpus parity check + +Phase B validation tooling for the managed-git migration +(see `docs/design/managed-git-migration.md`, §5 Phase B and §7 item 3). + +Runs the locally built `GitVersion.App` twice per repository/checkout shape — +once with `GITVERSION_GIT_BACKEND=libgit2`, once with `=managed` — using +`--output json --no-cache --no-fetch --no-normalize` (read-only, deterministic) +and diffs the JSON version variables field by field. + +## Usage + +```bash +# default corpus: GitVersion, GitReleaseManager, and this checkout itself +pwsh ./build/parity-corpus.ps1 + +# custom corpus / subset of variants / reuse binaries +pwsh ./build/parity-corpus.ps1 -Repos /path/to/repo -Variants full,worktree -SkipBuild + +# keep clones in a persistent cache +pwsh ./build/parity-corpus.ps1 -WorkDir ~/.cache/gitversion-parity +``` + +## Parameters + +| Parameter | Default | Meaning | +|---|---|---| +| `-Repos` | GitVersion, GitReleaseManager, local checkout | Git URLs or local paths | +| `-WorkDir` | `$TMPDIR/gitversion-parity-corpus` | Clone cache; repeat runs reuse it | +| `-Variants` | `full,shallow,worktree` | Checkout shapes to exercise | +| `-Configuration` / `-Framework` | `Release` / `net10.0` | Which App build to run | +| `-SkipBuild` | off | Reuse existing `GitVersion.App` binaries | + +## What it does per repository + +1. Full clone into the cache (skipped when already present). +2. Derives a **shallow** clone (`git clone --depth 1 file://`, run with + `--allow-shallow`) and a **linked worktree** (`git worktree add --detach`) — the two + CI-style shapes called out in the migration design. +3. Runs both backends per variant, normalizes/parses the JSON, and reports: + - `MATCH` — identical variables, + - `DIFF` — any field difference, or one backend failing while the other succeeds + (a parity failure; a readable per-field diff is printed), + - `ERROR` — variant setup failed or *both* backends refused the shape + (environment limitation, not counted as a parity failure). +4. Prints a summary table with per-backend elapsed milliseconds (coarse perf signal). + +## Exit code + +The number of repo/variant combinations with result `DIFF`. `0` = full parity. +Errors do not abort the run; remaining repos/variants are still checked. diff --git a/build/parity-corpus.ps1 b/build/parity-corpus.ps1 new file mode 100644 index 0000000000..5ac6e38974 --- /dev/null +++ b/build/parity-corpus.ps1 @@ -0,0 +1,299 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Real-world corpus parity check between the libgit2 and managed git backends. + +.DESCRIPTION + For every repository in the corpus this script prepares three checkout shapes + (full clone, shallow clone, linked worktree), runs the locally built + GitVersion.App once per backend (GITVERSION_GIT_BACKEND=libgit2|managed) with + read-only, cache-free flags, and diffs the JSON outputs field by field. + + Any field difference (or a one-sided failure) is a parity failure. The exit + code is the number of failing repo/variant combinations; 0 means full parity. + +.EXAMPLE + ./build/parity-corpus.ps1 + ./build/parity-corpus.ps1 -Repos https://github.com/GitTools/GitReleaseManager.git -Variants full,shallow + ./build/parity-corpus.ps1 -Repos /path/to/local/repo -WorkDir ~/parity-cache -SkipBuild +#> +[CmdletBinding()] +param( + # Git URLs or local paths to include in the corpus. + [string[]]$Repos, + + # Cache directory for clones; repeat runs reuse it. + [string]$WorkDir = (Join-Path ([IO.Path]::GetTempPath()) 'gitversion-parity-corpus'), + + # Checkout shapes to exercise per repository. + [ValidateSet('full', 'shallow', 'worktree')] + [string[]]$Variants = @('full', 'shallow', 'worktree'), + + [string]$Configuration = 'Release', + [string]$Framework = 'net10.0', + + # Skip building GitVersion.App (reuse existing binaries). + [switch]$SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $Repos) { + $Repos = @( + 'https://github.com/GitTools/GitVersion.git' + 'https://github.com/GitTools/GitReleaseManager.git' + $repoRoot + ) +} + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +function Invoke-Git { + param([string[]]$Arguments) + $output = & git @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed ($LASTEXITCODE): $($output | Out-String)" + } + $output +} + +function Get-CorpusName { + param([string]$Source) + $base = ([IO.Path]::GetFileName($Source.TrimEnd('/', '\'))) -replace '\.git$', '' + if (-not $base) { $base = 'repo' } + $hashBytes = [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Source)) + $suffix = -join ($hashBytes[0..3] | ForEach-Object { $_.ToString('x2') }) + "$base-$suffix" +} + +function Invoke-GitVersion { + <# Runs gitversion.dll with the given backend; returns exit code, stdout, stderr, elapsed ms. #> + param( + [string]$AppDll, + [string]$RepoPath, + [string]$Backend, + [string[]]$ExtraArgs + ) + $logFile = [IO.Path]::GetTempFileName() + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = 'dotnet' + $argList = @($AppDll, $RepoPath, '--output', 'json', '--no-cache', '--no-fetch', '--no-normalize', '-l', $logFile) + @($ExtraArgs) + foreach ($a in $argList) { + if (-not [string]::IsNullOrEmpty($a)) { $psi.ArgumentList.Add($a) } + } + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.EnvironmentVariables['GITVERSION_GIT_BACKEND'] = $Backend + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $process = [System.Diagnostics.Process]::Start($psi) + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + $sw.Stop() + + # On failure the reason usually only appears in the log; pull the first error line out. + $errorSummary = '' + if ($process.ExitCode -ne 0) { + $errorSummary = ($stderr + "`n" + $stdout).Trim() -split '\r?\n' | + Where-Object { $_ } | + Select-Object -First 1 + if (-not $errorSummary -and (Test-Path $logFile)) { + $errorSummary = Get-Content $logFile | + Where-Object { $_ -match 'EROR' -or $_ -match 'Exception' } | + Select-Object -First 2 | + Join-String -Separator ' ' + } + } + Remove-Item $logFile -ErrorAction SilentlyContinue + + [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout + StdErr = $stderr + ErrorSummary = [string]$errorSummary + ElapsedMs = $sw.ElapsedMilliseconds + } +} + +function ConvertFrom-GitVersionJson { + <# Extracts the JSON object from gitversion stdout and returns a hashtable, or $null. #> + param([string]$Text) + $start = $Text.IndexOf('{') + $end = $Text.LastIndexOf('}') + if ($start -lt 0 -or $end -le $start) { return $null } + try { + $Text.Substring($start, $end - $start + 1) | ConvertFrom-Json -AsHashtable + } catch { + $null + } +} + +function Compare-VersionVariables { + <# Field-by-field diff of two hashtables; returns a list of difference descriptions. #> + param([hashtable]$Left, [hashtable]$Right) + $differences = [System.Collections.Generic.List[string]]::new() + $keys = @($Left.Keys) + @($Right.Keys) | Sort-Object -Unique + foreach ($key in $keys) { + $l = if ($Left.ContainsKey($key)) { [string]$Left[$key] } else { '' } + $r = if ($Right.ContainsKey($key)) { [string]$Right[$key] } else { '' } + if ($l -cne $r) { + $differences.Add((" {0,-28} libgit2: {1} | managed: {2}" -f $key, $l, $r)) + } + } + $differences +} + +function Get-VariantPath { + <# Ensures the requested checkout shape exists under the repo's cache dir; returns its path. #> + param([string]$RepoCacheDir, [string]$Source, [string]$Variant) + + $fullDir = Join-Path $RepoCacheDir 'full' + if (-not (Test-Path (Join-Path $fullDir '.git'))) { + Write-Verbose "cloning $Source -> $fullDir" + $null = Invoke-Git @('clone', '--quiet', $Source, $fullDir) + } + if ($Variant -eq 'full') { return $fullDir } + + if ($Variant -eq 'shallow') { + $shallowDir = Join-Path $RepoCacheDir 'shallow' + if (-not (Test-Path (Join-Path $shallowDir '.git'))) { + # Derive the shallow clone from the local full clone: deterministic and offline. + $fullUri = ([System.Uri]::new($fullDir)).AbsoluteUri + Write-Verbose "creating shallow clone -> $shallowDir" + $null = Invoke-Git @('clone', '--quiet', '--depth', '1', $fullUri, $shallowDir) + } + return $shallowDir + } + + # worktree + $worktreeDir = Join-Path $RepoCacheDir 'worktree' + if (-not (Test-Path (Join-Path $worktreeDir '.git'))) { + Write-Verbose "creating linked worktree -> $worktreeDir" + $null = Invoke-Git @('-C', $fullDir, 'worktree', 'prune') + $null = Invoke-Git @('-C', $fullDir, 'worktree', 'add', '--detach', $worktreeDir) + } + return $worktreeDir +} + +# --------------------------------------------------------------------------- +# build once +# --------------------------------------------------------------------------- + +$appDll = Join-Path $repoRoot "src/GitVersion.App/bin/$Configuration/$Framework/gitversion.dll" +if (-not $SkipBuild) { + Write-Host "Building GitVersion.App ($Configuration, $Framework)..." + & dotnet build (Join-Path $repoRoot 'src/GitVersion.App/GitVersion.App.csproj') -c $Configuration -f $Framework --nologo -v q + if ($LASTEXITCODE -ne 0) { throw 'dotnet build failed.' } +} +if (-not (Test-Path $appDll)) { throw "GitVersion.App binary not found at $appDll (build it or drop -SkipBuild)." } + +$null = New-Item -ItemType Directory -Force -Path $WorkDir +Write-Host "Corpus cache: $WorkDir" +Write-Host "App: $appDll" +Write-Host '' + +# --------------------------------------------------------------------------- +# run corpus +# --------------------------------------------------------------------------- + +$results = [System.Collections.Generic.List[pscustomobject]]::new() + +foreach ($source in $Repos) { + $name = Get-CorpusName $source + $repoCacheDir = Join-Path $WorkDir $name + $null = New-Item -ItemType Directory -Force -Path $repoCacheDir + Write-Host "=== $source ($name) ===" + + foreach ($variant in $Variants) { + $record = [pscustomobject]@{ + Repo = $source + Variant = $variant + Result = 'ERROR' + Libgit2Ms = $null + ManagedMs = $null + Detail = '' + } + $results.Add($record) + + try { + $variantPath = Get-VariantPath -RepoCacheDir $repoCacheDir -Source $source -Variant $variant + } catch { + $record.Detail = "variant setup failed: $($_.Exception.Message)" + Write-Warning "[$name/$variant] $($record.Detail)" + continue + } + + [string[]]$extraArgs = @() + if ($variant -eq 'shallow') { $extraArgs = @('--allow-shallow') } + + $runs = @{} + foreach ($backend in 'libgit2', 'managed') { + $runs[$backend] = Invoke-GitVersion -AppDll $appDll -RepoPath $variantPath -Backend $backend -ExtraArgs $extraArgs + } + $record.Libgit2Ms = $runs['libgit2'].ElapsedMs + $record.ManagedMs = $runs['managed'].ElapsedMs + + $failed = @('libgit2', 'managed').Where({ $runs[$_].ExitCode -ne 0 }) + if ($failed.Count -eq 2) { + # Both backends refuse this shape identically: an environment limitation, not a parity break. + $record.Detail = 'both backends failed: ' + $runs['libgit2'].ErrorSummary + Write-Warning "[$name/$variant] $($record.Detail)" + continue + } + if ($failed.Count -eq 1) { + $only = $failed[0] + $record.Result = 'DIFF' + $record.Detail = "$only failed (exit $($runs[$only].ExitCode)) while the other backend succeeded: " + $runs[$only].ErrorSummary + Write-Host "[$name/$variant] PARITY FAILURE - $($record.Detail)" -ForegroundColor Red + continue + } + + $libgit2Json = ConvertFrom-GitVersionJson $runs['libgit2'].StdOut + $managedJson = ConvertFrom-GitVersionJson $runs['managed'].StdOut + if (-not $libgit2Json -or -not $managedJson) { + $record.Detail = 'could not parse JSON output from one or both backends' + Write-Warning "[$name/$variant] $($record.Detail)" + continue + } + + $differences = @(Compare-VersionVariables -Left $libgit2Json -Right $managedJson) + if ($differences.Count -eq 0) { + $record.Result = 'MATCH' + Write-Host "[$name/$variant] MATCH (libgit2 $($record.Libgit2Ms) ms, managed $($record.ManagedMs) ms)" -ForegroundColor Green + } else { + $record.Result = 'DIFF' + $record.Detail = "$($differences.Count) field(s) differ" + Write-Host "[$name/$variant] PARITY FAILURE - $($record.Detail):" -ForegroundColor Red + $differences | ForEach-Object { Write-Host $_ -ForegroundColor Red } + } + } + Write-Host '' +} + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- + +Write-Host '=== Summary ===' +$results | + Format-Table Repo, Variant, Result, Libgit2Ms, ManagedMs, Detail -AutoSize | + Out-String -Width 500 | + Write-Host + +$failures = @($results | Where-Object Result -eq 'DIFF') +$errors = @($results | Where-Object Result -eq 'ERROR') +if ($failures.Count -eq 0 -and $errors.Count -eq 0) { + Write-Host 'Full parity across the corpus.' -ForegroundColor Green +} elseif ($failures.Count -eq 0) { + Write-Host "No parity failures, but $($errors.Count) variant(s) could not be compared (ERROR)." -ForegroundColor Yellow +} else { + Write-Host "$($failures.Count) repo/variant combination(s) failed parity." -ForegroundColor Red +} +exit $failures.Count diff --git a/docs/design/managed-git-migration.md b/docs/design/managed-git-migration.md new file mode 100644 index 0000000000..e8e7a3e37d --- /dev/null +++ b/docs/design/managed-git-migration.md @@ -0,0 +1,273 @@ +# Replacing LibGit2Sharp with a managed Git implementation + +Research and migration plan for [#236](https://github.com/arturcic/GitVersion/issues/236) — researched 2026-07, based on a full survey of the `next/v7` tree. + +## 1. Motivation + +LibGit2Sharp wraps the native `libgit2` library and has been a recurring source of pain for GitVersion for close to a decade: + +- **Native binary load failures** on end-user machines and CI images — GitTools/GitVersion + [#1097](https://github.com/GitTools/GitVersion/issues/1097), + [#1203](https://github.com/GitTools/GitVersion/issues/1203), + [#1744](https://github.com/GitTools/GitVersion/issues/1744), + [#1852](https://github.com/GitTools/GitVersion/issues/1852), + [#2615](https://github.com/GitTools/GitVersion/issues/2615), + [#2884](https://github.com/GitTools/GitVersion/issues/2884). The failure mode is always the same class: + the RID-specific `libgit2-*.so/.dylib/.dll` doesn't match the runtime OS/arch/libc (musl vs glibc, + new Ubuntu OpenSSL versions, ARM variants) or cannot be located by the MSBuild task's assembly-load context. +- **Packaging weight**: `LibGit2Sharp.NativeBinaries` unpacks ~12 native binaries + (linux x64/arm/arm64/musl/ppc64le, osx x64/arm64, win x64/x86/arm64) into `runtimes//native/` + of every published artifact — and `GitVersion.MsBuild` ships that payload **per TFM** (net8.0/net9.0/net10.0). +- **Platform lag**: new RIDs, libcs and OS versions require waiting on libgit2 + LibGit2Sharp + NativeBinaries releases + (GitVersion is currently pinned to LibGit2Sharp 0.31.0). +- **Feature lag**: libgit2 trails git itself (SHA-256 repos, reftable, partial clone), and its global native state + complicates concurrent use inside MSBuild. + +Precedent: Nerdbank.GitVersioning faced the same problem and built a managed read-only engine +([dotnet/Nerdbank.GitVersioning#505](https://github.com/dotnet/Nerdbank.GitVersioning/issues/505), +[#521](https://github.com/dotnet/Nerdbank.GitVersioning/pull/521)). It became their default backend and delivered +**>10x throughput on history walks** compared to libgit2. NBGV kept libgit2 for write paths; the plan below goes one +step further and removes the native dependency entirely. + +## 2. Landscape: managed Git options in .NET (2026) + +| Candidate | Reads | Writes | Fetch/clone | Maintained | Assessment | +|---|---|---|---|---|---| +| [NBGV ManagedGit](https://github.com/dotnet/Nerdbank.GitVersioning/tree/main/src/NerdBank.GitVersioning/ManagedGit) | objects, packs (incl. deltas), refs; tuned for version calculation | No | No | Active, but internal to NBGV (MIT) | **Primary porting source** — proven pack/idx/delta code, the source of the >10x speedup | +| [GitReader](https://github.com/kekyo/GitReader) (kekyo) | full traversal: branches/tags/commits, packfiles, worktrees, index, .gitignore | No | No | Active (v1.18, .NET 10 TFMs, zero deps) | Porting inspiration for worktree handling and commit-graph; viable external dependency if vendoring were rejected | +| [ManagedGitLib](https://github.com/GlebChili/ManagedGitLib) | standalone extraction of NBGV ManagedGit | No | No | Stale (~2022) | Proof that vendoring/extracting NBGV's code works; not a dependency to take | +| DotGit, GitRead.Net, GitSharp, NGit | partial readers / ancient ports | No | No | Dead | Skip | +| `git` CLI shell-out (MinVer-style) | yes | yes | yes, with system credential helpers | n/a | **Chosen for writes + network** — requires `git` on PATH, which every CI normalization scenario already implies | + +**The decisive fact:** no maintained managed .NET library implements git *write* operations or the smart-HTTP/SSH +*transport* (fetch/clone). Every managed option is read-only. Any replacement strategy must therefore pair a managed +reader with something else for the write/network surface — and that something is the `git` CLI, which is guaranteed +present in exactly the scenarios (CI normalization, dynamic repositories) that need it. + +### Decisions + +1. **Hybrid backend**: vendored fully-managed reader for all read/history operations; `git` CLI shell-out for + writes and network. Zero native binaries ship with GitVersion afterwards. +2. **Vendored, not an external package**: the managed reader lives in this repo (ported from NBGV ManagedGit, MIT, + with attribution), giving full control over revwalk semantics — which version output depends on. +3. **`src/` (stable) tree first**; `new-cli` follows by source-linking the read-only subset, exactly as it + source-links the LibGit2Sharp adapter today. + +## 3. Current LibGit2Sharp surface (survey results) + +### 3.1 The seam is clean + +`GitVersion.Core` has **no LibGit2Sharp reference**. It consumes only its own abstraction in +`src/GitVersion.Core/Git/` (~20 interfaces: `IGitRepository`, `IMutatingGitRepository`, `IGitRepositoryInfo`, +`ICommit`, `IBranch`, `ITag`, `IReference`, `IRemote`, `IRefSpec`, `IObjectId`, their collections, and support types +`CommitFilter`, `AuthenticationInfo`, `ReferenceName`). Only three projects reference LibGit2Sharp: + +1. `src/GitVersion.LibGit2Sharp` — the production adapter +2. `src/GitVersion.Testing` — test fixtures +3. `new-cli/GitVersion.Core.Libgit2Sharp` — source-links the adapter's files, read-only subset + (excludes `GitRepository.mutating.cs` and `GitRepositoryInfo.cs`) + +### 3.2 Read surface (the bulk — must be reimplemented) + +- Repository discovery and open: walk-up discovery, `.git` file indirection (worktrees/submodules), + commondir split, bare detection, shallow detection (`repo.Info.IsShallow`) +- HEAD, branch/tag/reference/remote enumeration (incl. glob queries via `Refs.FromGlob`) +- Commit metadata: id, parents, committer `When`, message +- **Revwalk**: `CommitCollection.QueryBy(CommitFilter)` → libgit2 `IncludeReachableFrom` / + `ExcludeReachableFrom` / `FirstParentOnly` / `SortBy` (Topological, Time). Ordering is + version-output-affecting. +- **Merge-base**: `repo.ObjectDatabase.FindMergeBase(c1, c2)` +- **Tree diff**: `ICommit.DiffPaths` via `repo.Diff.Compare(tree, parentTree)` (changed paths only) +- **Status**: `UncommittedChangesCount()` via `Diff.Compare(Head.Tip.Tree, DiffTargets.Index | DiffTargets.WorkingDirectory)` with `RetrieveStatus()` fallback + +### 3.3 Write + network surface (small, one consumer) + +Everything mutating funnels through **`src/GitVersion.Core/Core/GitPreparer.cs`** (CI normalization and dynamic +repositories), implemented in `src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs` and the collection wrappers: + +- `Clone(url, workdir, auth)` — no-checkout clone with username/password credentials +- `Fetch(remote, refSpecs, auth, log)` — authenticated HTTPS (username/password only; no SSH path exists today) +- `Checkout(spec)`; `CreateBranchForPullRequestBranch(auth)` (uses network `ls-remote` under the hood) +- `References.Add/UpdateTarget`, `Branches.UpdateTrackedBranch/Remove`, `Remotes.Update(refspec)/Remove` +- Error mapping is already string-based (parses "401"/"404" out of exception messages); + `LockedFileException` is retried via Polly (`RepositoryExtensions.RunSafe`) + +### 3.4 Known abstraction leaks (to fix during migration) + +- Public `LibGit2SharpExtensions.ToGitRepository(IRepository)` exposes a LibGit2Sharp type +- `CommitFilter.SortBy` is numerically cast to `LibGit2Sharp.CommitSortStrategies` +- `CommitFilter.IncludeReachableFrom`/`ExcludeReachableFrom` are typed `object?` + +### 3.5 Test fixtures + +`src/GitVersion.Testing` (~300 LoC of real LibGit2Sharp usage in `Fixtures/RepositoryFixtureBase.cs` + +`Extensions/GitTestExtensions.cs`) is *write-heavy*: stage/commit/tag/branch/merge-no-ff/checkout/clone/fetch/config. +It already shells out to real `git` for `init -b`, shallow `pull --depth 1` and `gc` — so a pure git-CLI fixture +backend is a natural completion of an existing pattern. Six test files additionally use LibGit2Sharp directly +(mostly `ToGitRepository()` and `Signature`). + +## 4. Target architecture + +``` +src/ + GitVersion.Git.Managed/ vendored managed reader; the raw git-format layers below + are Core-free, Core is referenced only once the interface + implementations land (final Phase B step) + Objects/ GitObjectId (hash-agnostic), pack + .idx v2 readers, delta streams, + pack cache, loose-object reader, multi-pack-index reader + Parsing/ commit parser (author/committer/when/message/encoding), tree parser, + annotated-tag parser + Refs/ loose refs, packed-refs, symbolic refs, HEAD, glob enumeration + Repository/ discovery (walk-up, .git file, commondir), worktree resolver, shallow info + History/ revwalk (topo/time, first-parent, exclusions — libgit2-parity), + merge-base (paint-down-to-common), commit-graph reader (later, optional) + Diff/ recursive tree-vs-tree changed-paths diff (no rename detection) + Status/ .git/index v2–v4 reader, workdir/index status for UncommittedChangesCount + CommandLine/ git CLI executor + mutator (writes + network only) — absorbed from the + interim GitVersion.Git.CommandLine project at the final Phase B step +``` + +No separate adapter project: `GitVersion.Git.Managed` implements `IGitRepository` (managed reader) +and `IMutatingGitRepository` (delegating to the CLI mutator) **directly**, mirroring how +`GitVersion.LibGit2Sharp` implements the same interfaces over libgit2 today — same file layout, +so the DI surface is unchanged. + +One-library end state: all git interaction (managed reads + CLI writes) lives in +`GitVersion.Git.Managed`, just as `GitVersion.LibGit2Sharp` is the single libgit2 library today. +`GitVersion.Git.CommandLine` exists only as an interim project (created in Phase A so the CLI +mutator could ship while reads stayed on libgit2); when it folds into `GitVersion.Git.Managed` +at the final Phase B step, `GitVersion.LibGit2Sharp` re-points its mutator `ProjectReference` +to `GitVersion.Git.Managed` for the dual-backend window and that reference disappears with the +project's deletion in Phase E. + +Port-vs-fresh decisions: + +| Component | Decision | +|---|---| +| Pack/idx/delta reading, pack cache, loose objects, object id | **Port from NBGV ManagedGit** (MIT, attribution in headers + NOTICE) | +| Commit/tree parsing | Port + extend (NBGV skips author/message — GitVersion needs them) | +| Annotated tags, multi-pack-index, discovery/worktrees, shallow | Fresh (small, well-specified formats) | +| **Revwalk**, **merge-base**, tree diff, index/status | **Fresh** — highest-parity-risk pieces, written against libgit2's documented algorithms | +| SHA-256 repos | Hash-agnostic `GitObjectId` from day one; detect `extensions.objectformat=sha256` and fail with a clear message initially (parity: libgit2 can't read them either) | +| Reftable (`extensions.refstorage=reftable`) | Detect and fail with a clear message; reader in backlog | + +CLI executor hygiene: + +- Arguments via `ProcessStartInfo.ArgumentList` (no shell, no quoting bugs); `LC_ALL=C`, `GIT_TERMINAL_PROMPT=0`, + `-c core.quotepath=false` +- **Plumbing-only parsing** (`rev-parse`, `ls-remote`, `update-ref`, `symbolic-ref`, `for-each-ref --format`, + `config`); porcelain commands (`clone`, `fetch`, `checkout`, `branch`) invoked for side effects only +- Auth: per-invocation `-c http..extraHeader=Authorization: Basic ` — never URL userinfo, + never persisted to config +- stderr classification mapped to today's exceptions: `.lock` failures → `LockedFileException` + (keeps the Polly retry untouched), 401/403/404 → the same messages `GitPreparer` expects +- One-time `git version` probe; require ≥ 2.30, actionable error if git is absent when a mutating + operation is requested + +## 5. Phased migration (every phase shippable) + +### Release strategy: one switch, two backends, a public testing window + +A single public environment variable — **`GITVERSION_GIT_BACKEND=libgit2|managed`** — selects the entire +backend. `managed` opts into the new implementation stack as far as it exists at each release (first the +CLI mutator, later also the managed reader). The version-to-default mapping is explicit: + +| Release | Default backend | Opt-in / opt-out | +|---|---|---| +| **v7.0** | `libgit2` | `GITVERSION_GIT_BACKEND=managed` to test the new backend | +| **v7.1** | `managed` | `GITVERSION_GIT_BACKEND=libgit2` to fall back | +| v7.x (later) | `managed` | libgit2 removed (Phase E) once the fallback has gone several releases unused/regression-free | + +Both backends ship side by side throughout the v7.0–v7.x window so users can validate `managed` against +their real repositories and report issues before libgit2 is dropped. For that whole window, the CI +unit-test matrix runs the full suite against both backends (`git_backend` dimension in `_unit_tests.yml`) — +both legs must stay green. + +### Phase A — CLI mutator first (reads stay on libgit2) · ~2–3 weeks +Replace `GitRepository.mutating.cs` + mutating collection members with `GitVersion.Git.CommandLine` +(an interim project — it merges into `GitVersion.Git.Managed` at the final Phase B step, see §4). +Reads keep using libgit2; the wrapper drops its cached collection wrappers after each CLI mutation so +external ref changes become visible (the libgit2 handle itself must not be disposed — wrappers already +handed out reference its native memory). Selected via `GITVERSION_GIT_BACKEND=managed`. +This immediately retires the most fragile native code paths (network, SSL, credentials). + +### Phase B-pre — interface cleanups · ~3–5 days +Core-owned `CommitSortStrategies` enum (explicit mapping in the libgit2 adapter instead of a numeric cast), +typed `CommitFilter` reachable-from members, remove/internalize `ToGitRepository`. + +### Phase B — managed reader behind a flag · ~8–12 weeks +Build `GitVersion.Git.Managed` bottom-up with per-layer unit tests against git-CLI-generated fixture repos +(loose/packed/mixed objects, packed-refs, worktrees, shallow, multi-pack-index, index v4). +The final B step takes the `GitVersion.Core` reference, lands the direct `IGitRepository`/ +`IMutatingGitRepository` implementations, absorbs `GitVersion.Git.CommandLine` (see §4), and wires +backend selection via `GITVERSION_GIT_BACKEND=managed|libgit2` (default `libgit2`). Validation: +- **CI matrix**: full integration suites (`GitVersion.Core.Tests`, `GitVersion.App.Tests`) run on both backends, + three OSes — the suites assert exact SemVer strings over complex histories and are the strongest parity oracle +- **DualBackendParityTests**: open the same fixture with both backends; deep-equality on ref enumeration, + order-sensitive `QueryBy` sequences, `FindMergeBase` over all commit pairs (incl. criss-cross and + equal-timestamp fixtures), `DiffPaths`, `UncommittedChangesCount` +- **Real-world corpus script**: `gitversion /nocache /output json` diffed across backends on this repo, + GitReleaseManager, dotnet/runtime, a shallow CI-style clone, and a worktree checkout + +### Phase C — default flip in v7.1 · ~1 week + multi-release soak +**v7.0 ships with default `libgit2`** (managed opt-in); **v7.1 flips the default to `managed`** with +`GITVERSION_GIT_BACKEND=libgit2` as the fallback while users validate the new backend. The dual-backend +CI matrix stays green throughout the window. + +### Phase D — fixture migration (parallel with B) · ~2–3 weeks +`GitVersion.Testing` moves to pure git-CLI writes via the existing `ExecuteGitCmd` pattern: +deterministic `GIT_AUTHOR_*`/`GIT_COMMITTER_*` env (several tests advance commit time explicitly), +`git commit --allow-empty`, `-c commit.gpgsign=false -c gc.auto=0`. Migrate the six direct-usage test files. +If suite time regresses from process spawns, batch history creation with `git fast-import`. + +### Phase E — remove LibGit2Sharp · ~1–2 weeks +Delete `src/GitVersion.LibGit2Sharp` and `new-cli/GitVersion.Core.Libgit2Sharp`; drop the package from +`Directory.Packages.props`; re-point new-cli source-linking at `GitVersion.Git.Managed` +(read-only subset). Add a packaging assertion test: **zero `runtimes/**/native/*` entries** in the +`GitVersion.MsBuild` and `GitVersion.Tool` nupkgs. Document the new runtime requirement: `git` on PATH is needed +**only** for dynamic-repo/CI-normalization scenarios — pure version calculation on a prepared checkout needs no +git binary at all (a strict improvement for MSBuild-task users). + +### Phase F (optional) — performance accelerators · ~2–3 weeks +Commit-graph reader (generation numbers for topo sort and merge-base), benchmark-driven pack cache tuning. + +## 6. Risks and mitigations + +| Risk | Mitigation | +|---|---| +| **Revwalk ordering divergence → wrong versions** | Port libgit2's `revwalk.c` algorithm precisely (time-queue with insertion-order tiebreak, Kahn-style topo over it, mark-uninteresting propagation); order-sensitive dual-backend sequence tests; corpus diffing as the Phase C gate | +| Merge-base candidate choice on criss-cross merges (libgit2 returns one of several) | Replicate libgit2's paint-down selection; assert *version output* on criss-cross fixtures plus one SHA-level canary test to detect drift | +| Shallow clones (the CI default) | `.git/shallow` boundaries treated as parentless in the walker; existing `MakeShallow` fixture covers it | +| Worktrees / `.git`-file / commondir | Dedicated `WorktreeResolver`; existing worktree tests + new fixtures | +| Rename detection assumed in `DiffPaths` | Verify adapter options at Phase B kickoff — libgit2's default `Compare` doesn't do rename detection, so a plain recursive tree diff is expected to be exact parity | +| Performance regressions | Port NBGV's pack cache; BenchmarkDotNet suite (end-to-end + revwalk/merge-base/status micro); Phase C gate: no scenario >20% slower — expectation is large wins per NBGV precedent | +| git CLI availability/version drift | Only the mutator needs git; version probe (≥2.30) with actionable error; CI leg on a container with the oldest supported git | +| CLI output stability | Plumbing + `--format` parsing only; stderr matching limited to error classification with conservative fallback (unknown → generic error carrying stderr, as today) | +| Locale/encoding | `LC_ALL=C`; honor the commit `encoding` header with Latin-1 fallback (matching git); `core.quotepath=false` | +| Concurrency (parallel MSBuild tasks) | Managed reader is read-only and lock-free; per-instance or thread-safe pack cache; removes libgit2's native global state entirely | +| SHA-256 / reftable repos | Detect and fail with clear messages (no regression — libgit2 can't read them either); hash-agnostic design keeps the door open | +| License/attribution of vendored code | MIT headers + NOTICE entries for NBGV ManagedGit (and GitReader if any code is taken) | + +## 7. Verification strategy + +1. Full integration suite × backend × OS matrix green through Phases B–D +2. `DualBackendParityTests` structural deep-equality on adversarial fixtures (equal timestamps, criss-cross, + octopus merges, packed+loose refs, annotated+lightweight tags on one commit, shallow, worktree, + multi-pack-index, index v4) +3. Real-world corpus JSON diffing recorded before the Phase C flip +4. Benchmark gate (≥ parity everywhere, expected wins on history walks) +5. Packaging assertion: no native binaries in shipped nupkgs (Phase E) + +## 8. Effort summary + +| Phase | Scope | Estimate | +|---|---|---| +| A | git-CLI mutator + auth/error/lock mapping | 2–3 weeks | +| B-pre | interface cleanups | 3–5 days | +| B | managed reader + direct interface implementations + parity harness | 8–12 weeks | +| C | default flip + soak | 1 week + 1 release | +| D | fixture migration (parallel with B) | 2–3 weeks | +| E | LibGit2Sharp removal, new-cli re-link, packaging tests | 1–2 weeks | +| F | commit-graph accelerator (optional) | 2–3 weeks | + +**Total to a LibGit2Sharp-free GitVersion: ~4–5 months elapsed including soak cycles.** diff --git a/docs/input/docs/migration/v6-to-v7.md b/docs/input/docs/migration/v6-to-v7.md index da0d0b5c76..1fe6d75402 100644 --- a/docs/input/docs/migration/v6-to-v7.md +++ b/docs/input/docs/migration/v6-to-v7.md @@ -99,3 +99,29 @@ gitversion --url https://github.com/org/repo.git --branch main --username user - ``` For current command details and examples, see [CLI Arguments](/docs/usage/cli/arguments). + +## Git backend + +GitVersion v7 introduces a fully managed Git backend as an alternative to the native LibGit2Sharp (libgit2) implementation. The backend is selected with the `GITVERSION_GIT_BACKEND` environment variable. When the variable is not set (or empty), the release's default backend is used — you never need to set it. Setting it to any value other than `libgit2` or `managed` (case-insensitive) is an error: GitVersion fails fast instead of silently running the default backend with a typo unnoticed. + +:::{.alert .alert-info} +In v7.0 the `libgit2` backend remains the **default** — behaviour is unchanged unless you opt in. Set `GITVERSION_GIT_BACKEND=managed` to try the managed backend and help validate it. In v7.1 the default flips to `managed`, with `GITVERSION_GIT_BACKEND=libgit2` available as a fallback. Both backends ship side by side for several releases before libgit2 is removed. +::: + +- `libgit2` — the native, libgit2-based backend (default in v7.0). +- `managed` — a managed implementation for all read/history operations, combined with the `git` command-line executable for network and write operations (clone, fetch, checkout, and CI repository normalization). + +:::{.alert .alert-warning} +When using the `managed` backend, the `git` executable must be available on the `PATH` **only** for the network/normalization scenarios above (dynamic repositories, build-agent normalization). Plain version calculation on an already-prepared checkout does not require `git` on the `PATH`. +::: + +## Environment variables + +The environment variables relevant to migrating from v6 to v7: + +| Variable | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `GITVERSION_GIT_BACKEND` | Selects the Git backend: `libgit2` (default in v7.0) or `managed`. See [Git backend](#git-backend). | +| `GITVERSION_USE_V6_ARGUMENT_PARSER` | Set to `true` to temporarily restore the legacy v6 (`/switch`) argument parser. Removed in a future release. | +| `GITVERSION_REMOTE_USERNAME` | Alternative to `--username` for dynamic-repository credentials. | +| `GITVERSION_REMOTE_PASSWORD` | Alternative to `--password` for dynamic-repository credentials. | diff --git a/new-cli/GitVersion.Common/GitVersion.Common.csproj b/new-cli/GitVersion.Common/GitVersion.Common.csproj index 065fa33110..75cd78f6a2 100644 --- a/new-cli/GitVersion.Common/GitVersion.Common.csproj +++ b/new-cli/GitVersion.Common/GitVersion.Common.csproj @@ -5,6 +5,10 @@ + + + + diff --git a/src/.run/cli (libgit2sharp).run.xml b/src/.run/cli (libgit2sharp).run.xml new file mode 100644 index 0000000000..f3f44d0b69 --- /dev/null +++ b/src/.run/cli (libgit2sharp).run.xml @@ -0,0 +1,26 @@ + + + + \ No newline at end of file diff --git a/src/.run/cli (managed).run.xml b/src/.run/cli (managed).run.xml new file mode 100644 index 0000000000..effd3db680 --- /dev/null +++ b/src/.run/cli (managed).run.xml @@ -0,0 +1,26 @@ + + + + \ No newline at end of file diff --git a/src/.run/cli (version).run.xml b/src/.run/cli (version).run.xml new file mode 100644 index 0000000000..aaefccb438 --- /dev/null +++ b/src/.run/cli (version).run.xml @@ -0,0 +1,23 @@ + + + + \ No newline at end of file diff --git a/src/GitVersion.App/CliHost.cs b/src/GitVersion.App/CliHost.cs index edfab24e11..7164911260 100644 --- a/src/GitVersion.App/CliHost.cs +++ b/src/GitVersion.App/CliHost.cs @@ -1,6 +1,7 @@ using GitVersion.Agents; using GitVersion.Configuration; using GitVersion.Extensions; +using GitVersion.Git; using GitVersion.Output; using Serilog; using Serilog.Core; @@ -33,7 +34,9 @@ private static void RegisterGitVersionModules(IServiceCollection services, strin services.AddModule(new GitVersionConfigurationModule()); services.AddModule(new GitVersionOutputModule()); - services.AddModule(new GitVersionLibGit2SharpModule()); + services.AddModule(GitBackendSelector.Resolve() == GitBackend.Managed + ? new GitVersionManagedGitModule() + : new GitVersionLibGit2SharpModule()); var envValue = SysEnv.GetEnvironmentVariable("GITVERSION_USE_V6_ARGUMENT_PARSER"); var useLegacyParser = string.Equals(envValue, "true", StringComparison.OrdinalIgnoreCase); diff --git a/src/GitVersion.App/GitVersion.App.csproj b/src/GitVersion.App/GitVersion.App.csproj index 54f41274b1..45f1546e28 100644 --- a/src/GitVersion.App/GitVersion.App.csproj +++ b/src/GitVersion.App/GitVersion.App.csproj @@ -28,6 +28,7 @@ + diff --git a/src/GitVersion.App/GitVersionExecutor.cs b/src/GitVersion.App/GitVersionExecutor.cs index 9884eb7d3d..1c7d920e84 100644 --- a/src/GitVersion.App/GitVersionExecutor.cs +++ b/src/GitVersion.App/GitVersionExecutor.cs @@ -124,6 +124,8 @@ private void Initialize(GitVersionOptions gitVersionOptions) { this.logger.LogInformation("Working directory: {WorkingDirectory}", workingDirectory); } + + this.logger.LogInformation("Git backend: {GitBackend}", GitBackendSelector.ResolveName()); } private bool VerifyAndDisplayConfiguration(GitVersionOptions gitVersionOptions) diff --git a/src/GitVersion.Core.Tests/Core/DualBackendParityTests.cs b/src/GitVersion.Core.Tests/Core/DualBackendParityTests.cs new file mode 100644 index 0000000000..14fbf16226 --- /dev/null +++ b/src/GitVersion.Core.Tests/Core/DualBackendParityTests.cs @@ -0,0 +1,551 @@ +using System.IO.Abstractions; +using GitVersion.Git; +using GitVersion.Helpers; +using GitVersion.Testing.Extensions; + +namespace GitVersion.Tests; + +/// +/// Opens the same on-disk repository with both Git backends (libgit2 and managed) and +/// deep-compares every read surface: reference/branch/tag enumeration, order-sensitive +/// commit walks, merge bases over all commit pairs, diff paths and the uncommitted +/// changes count. This is the direct parity oracle demanded by the managed-git +/// migration plan, independent of which backend the test run selects globally. +/// +[TestFixture] +public class DualBackendParityTests : TestBase +{ + private static readonly CommitSortStrategies[] SortCombinations = + [ + CommitSortStrategies.None, + CommitSortStrategies.Time, + CommitSortStrategies.Topological, + CommitSortStrategies.Topological | CommitSortStrategies.Time, + CommitSortStrategies.Time | CommitSortStrategies.Reverse, + CommitSortStrategies.Topological | CommitSortStrategies.Time | CommitSortStrategies.Reverse + ]; + + [Test] + public void ReferencesAreIdenticalOnBothBackends() + { + using var fixture = CreateComplexFixture(); + using var backends = OpenBothBackends(fixture); + + static IEnumerable Describe(IGitRepository repository) => + repository.References.Select(r => $"{r.Name.Canonical} -> {r.TargetIdentifier} ({r.ReferenceTargetId?.Sha ?? "symbolic"})"); + + Describe(backends.Managed).ShouldBe(Describe(backends.LibGit2)); + + var libGit2Head = backends.LibGit2.References.Head.ShouldNotBeNull(); + var managedHead = backends.Managed.References.Head.ShouldNotBeNull(); + managedHead.Name.Canonical.ShouldBe(libGit2Head.Name.Canonical); + managedHead.TargetIdentifier.ShouldBe(libGit2Head.TargetIdentifier); + + static IEnumerable FromGlob(IGitRepository repository) => + repository.References.FromGlob("refs/heads/*").Select(r => $"{r.Name.Canonical} -> {r.TargetIdentifier}"); + + FromGlob(backends.Managed).ShouldBe(FromGlob(backends.LibGit2)); + } + + [Test] + public void BranchesAreIdenticalOnBothBackends() + { + using var fixture = CreateComplexFixture(); + using var backends = OpenBothBackends(fixture); + + static IEnumerable Describe(IGitRepository repository) => + repository.Branches.Select(b => $"{b.Name.Canonical}@{b.Tip?.Sha} remote:{b.IsRemote} tracking:{b.IsTracking} detached:{b.IsDetachedHead}"); + + Describe(backends.Managed).ShouldBe(Describe(backends.LibGit2)); + + foreach (var name in new[] { "main", "develop", "refs/heads/develop", "feature/complex", "missing" }) + { + var libGit2Branch = backends.LibGit2.Branches[name]; + var managedBranch = backends.Managed.Branches[name]; + (managedBranch?.Name.Canonical).ShouldBe(libGit2Branch?.Name.Canonical, $"lookup of '{name}'"); + (managedBranch?.Tip?.Sha).ShouldBe(libGit2Branch?.Tip?.Sha, $"tip of '{name}'"); + } + + backends.Managed.Head.Name.Canonical.ShouldBe(backends.LibGit2.Head.Name.Canonical); + (backends.Managed.Head.Tip?.Sha).ShouldBe(backends.LibGit2.Head.Tip?.Sha); + backends.Managed.IsHeadDetached.ShouldBe(backends.LibGit2.IsHeadDetached); + backends.Managed.IsShallow.ShouldBe(backends.LibGit2.IsShallow); + } + + [Test] + public void TagsAreIdenticalOnBothBackends() + { + using var fixture = CreateComplexFixture(); + using var backends = OpenBothBackends(fixture); + + static IEnumerable Describe(IGitRepository repository) => + repository.Tags.Select(t => $"{t.Name.Canonical} target:{t.TargetSha} commit:{t.Commit.Sha}"); + + Describe(backends.Managed).ShouldBe(Describe(backends.LibGit2)); + } + + [Test] + public void CommitLogsAreIdenticalOnBothBackends() + { + using var fixture = CreateComplexFixture(); + using var backends = OpenBothBackends(fixture); + + static IEnumerable Describe(IGitRepository repository) => + repository.Commits.Select(c => $"{c.Sha} when:{c.When:O} merge:{c.IsMergeCommit} parents:{string.Join(',', c.Parents.Select(p => p.Sha))} message:{c.Message}"); + + Describe(backends.Managed).ShouldBe(Describe(backends.LibGit2)); + + foreach (var branchName in new[] { "main", "develop", "feature/complex" }) + { + var libGit2Commits = backends.LibGit2.Branches[branchName].ShouldNotBeNull().Commits.Select(c => c.Sha); + var managedCommits = backends.Managed.Branches[branchName].ShouldNotBeNull().Commits.Select(c => c.Sha); + managedCommits.ShouldBe(libGit2Commits, $"commits of '{branchName}'"); + } + } + + [Test] + public void QueryBySequencesAreIdenticalOnBothBackends() + { + using var fixture = CreateCrissCrossFixture(); + using var backends = OpenBothBackends(fixture); + + var libGit2Commits = CollectAllCommits(backends.LibGit2); + var managedCommits = CollectAllCommits(backends.Managed); + managedCommits.Keys.Order().ShouldBe(libGit2Commits.Keys.Order()); + + string?[] excludeCandidates = [null, .. libGit2Commits.Keys.Order().Take(3)]; + + foreach (var sortBy in SortCombinations) + { + foreach (var firstParentOnly in new[] { false, true }) + { + foreach (var excludeSha in excludeCandidates) + { + var description = $"sort:{sortBy} firstParent:{firstParentOnly} exclude:{excludeSha ?? "none"}"; + + var libGit2Result = backends.LibGit2.Commits.QueryBy(new CommitFilter + { + IncludeReachableFrom = backends.LibGit2.Branches["main"], + ExcludeReachableFrom = excludeSha is null ? null : libGit2Commits[excludeSha], + SortBy = sortBy, + FirstParentOnly = firstParentOnly + }).Select(c => c.Sha); + + var managedResult = backends.Managed.Commits.QueryBy(new CommitFilter + { + IncludeReachableFrom = backends.Managed.Branches["main"], + ExcludeReachableFrom = excludeSha is null ? null : managedCommits[excludeSha], + SortBy = sortBy, + FirstParentOnly = firstParentOnly + }).Select(c => c.Sha); + + managedResult.ShouldBe(libGit2Result, description); + } + } + } + } + + [Test] + public void MergeBasesAreIdenticalOnBothBackendsForAllCommitPairs() + { + using var fixture = CreateCrissCrossFixture(); + using var backends = OpenBothBackends(fixture); + + var libGit2Commits = CollectAllCommits(backends.LibGit2); + var managedCommits = CollectAllCommits(backends.Managed); + var shas = libGit2Commits.Keys.Order().ToList(); + + foreach (var first in shas) + { + foreach (var second in shas) + { + var libGit2MergeBase = backends.LibGit2.FindMergeBase(libGit2Commits[first], libGit2Commits[second]); + var managedMergeBase = backends.Managed.FindMergeBase(managedCommits[first], managedCommits[second]); + (managedMergeBase?.Sha).ShouldBe(libGit2MergeBase?.Sha, $"merge base of {first[..7]} and {second[..7]}"); + } + } + } + + [Test] + public void DiffPathsAreIdenticalOnBothBackendsForAllCommits() + { + using var fixture = CreateComplexFixture(); + using var backends = OpenBothBackends(fixture); + + var libGit2Commits = CollectAllCommits(backends.LibGit2); + var managedCommits = CollectAllCommits(backends.Managed); + + foreach (var sha in libGit2Commits.Keys.Order()) + { + managedCommits[sha].DiffPaths.ShouldBe(libGit2Commits[sha].DiffPaths, $"diff paths of {sha[..7]}"); + } + } + + [Test] + public void UncommittedChangesCountIsIdenticalOnBothBackends() + { + using var fixture = new EmptyRepositoryFixture(); + var signature = new LibGit2Sharp.Signature("unit test", "test@example.com", DateTimeOffset.Now); + + var trackedFile = FileSystemHelper.Path.Combine(fixture.RepositoryPath, "tracked.txt"); + File.WriteAllText(trackedFile, "one"); + LibGit2Sharp.Commands.Stage(fixture.Repository, "tracked.txt"); + fixture.Repository.Commit("add tracked file", signature, signature, new LibGit2Sharp.CommitOptions()); + + File.WriteAllText(trackedFile, "two"); + File.WriteAllText(FileSystemHelper.Path.Combine(fixture.RepositoryPath, "untracked.txt"), "new"); + File.WriteAllText(FileSystemHelper.Path.Combine(fixture.RepositoryPath, "staged.txt"), "staged"); + LibGit2Sharp.Commands.Stage(fixture.Repository, "staged.txt"); + + using var backends = OpenBothBackends(fixture); + backends.Managed.UncommittedChangesCount().ShouldBe(backends.LibGit2.UncommittedChangesCount()); + } + + [Test] + public void EmptyRepositoryBehavesIdenticallyOnBothBackends() + { + using var fixture = new EmptyRepositoryFixture(); + File.WriteAllText(FileSystemHelper.Path.Combine(fixture.RepositoryPath, "untracked.txt"), "new"); + + using var backends = OpenBothBackends(fixture); + + backends.Managed.Head.Name.Canonical.ShouldBe(backends.LibGit2.Head.Name.Canonical); + backends.Managed.Head.Tip.ShouldBe(backends.LibGit2.Head.Tip); + backends.Managed.IsHeadDetached.ShouldBe(backends.LibGit2.IsHeadDetached); + backends.Managed.UncommittedChangesCount().ShouldBe(backends.LibGit2.UncommittedChangesCount()); + } + + [Test] + public void DetachedHeadBehavesIdenticallyOnBothBackends() + { + using var fixture = CreateComplexFixture(); + var detachAt = fixture.Repository.Head.Tip.Parents.First(); + LibGit2Sharp.Commands.Checkout(fixture.Repository, detachAt); + + using var backends = OpenBothBackends(fixture); + + backends.Managed.IsHeadDetached.ShouldBeTrue(); + backends.Managed.IsHeadDetached.ShouldBe(backends.LibGit2.IsHeadDetached); + backends.Managed.Head.Name.Canonical.ShouldBe(backends.LibGit2.Head.Name.Canonical); + backends.Managed.Head.IsDetachedHead.ShouldBe(backends.LibGit2.Head.IsDetachedHead); + (backends.Managed.Head.Tip?.Sha).ShouldBe(backends.LibGit2.Head.Tip?.Sha); + } + + [Test] + public void RemotesAndRemoteBranchesAreIdenticalOnBothBackends() + { + using var fixture = new RemoteRepositoryFixture(); + using var backends = OpenBothBackends(fixture.LocalRepositoryFixture); + + static IEnumerable DescribeRemotes(IGitRepository repository) => + repository.Remotes.Select(r => + $"{r.Name} {r.Url} fetch:[{string.Join(';', r.FetchRefSpecs.Select(s => s.Specification))}] push:[{string.Join(';', r.PushRefSpecs.Select(s => s.Specification))}]"); + + DescribeRemotes(backends.Managed).ShouldBe(DescribeRemotes(backends.LibGit2)); + + var libGit2Remote = backends.LibGit2.Remotes["origin"].ShouldNotBeNull(); + var managedRemote = backends.Managed.Remotes["origin"].ShouldNotBeNull(); + managedRemote.Url.ShouldBe(libGit2Remote.Url); + + foreach (var (managedSpec, libGit2Spec) in managedRemote.FetchRefSpecs.Zip(libGit2Remote.FetchRefSpecs)) + { + managedSpec.Specification.ShouldBe(libGit2Spec.Specification); + managedSpec.Direction.ShouldBe(libGit2Spec.Direction); + managedSpec.Source.ShouldBe(libGit2Spec.Source); + managedSpec.Destination.ShouldBe(libGit2Spec.Destination); + } + + static IEnumerable DescribeBranches(IGitRepository repository) => + repository.Branches.Select(b => $"{b.Name.Canonical}@{b.Tip?.Sha} remote:{b.IsRemote} tracking:{b.IsTracking}"); + + DescribeBranches(backends.Managed).ShouldBe(DescribeBranches(backends.LibGit2)); + } + + [Test] + public void RepositoryPathsAreIdenticalOnBothBackends() + { + using var fixture = CreateComplexFixture(); + using var backends = OpenBothBackends(fixture); + + backends.Managed.Path.ShouldBe(backends.LibGit2.Path); + backends.Managed.WorkingDirectory.ShouldBe(backends.LibGit2.WorkingDirectory); + } + + [Test] + public void BareRepositoryHasNullProjectRootDirectoryOnBothBackends() + { + var barePath = FileSystemHelper.Path.GetRepositoryTempPath(); + LibGit2Sharp.Repository.Init(barePath, isBare: true); + try + { + var fileSystem = new FileSystem(); + var options = Options.Create(new GitVersionOptions { WorkingDirectory = barePath }); + + IGitRepositoryInfo libGit2Info = new GitRepositoryInfo(fileSystem, options); + IGitRepositoryInfo managedInfo = new ManagedGitRepositoryInfo(fileSystem, options); + + // Discovery succeeds for a bare repository, but there is no working directory: + // the project root is legitimately null and resolving it must not throw. + libGit2Info.ProjectRootDirectory.ShouldBeNull(); + managedInfo.ProjectRootDirectory.ShouldBeNull(); + + managedInfo.DotGitDirectory.ShouldNotBeNull(); + managedInfo.DotGitDirectory.ShouldBe(libGit2Info.DotGitDirectory); + } + finally + { + FileSystemHelper.Directory.DeleteDirectory(barePath); + } + } + + [Test] + public void OctopusMergeBehavesIdenticallyOnBothBackends() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.MakeACommit("base"); + fixture.BranchTo("topic/one"); + fixture.MakeACommit("topic one"); + fixture.Checkout("main"); + fixture.BranchTo("topic/two"); + fixture.MakeACommit("topic two"); + fixture.Checkout("main"); + fixture.MakeACommit("main one"); + GitTestExtensions.ExecuteGitCmd($"-C {fixture.RepositoryPath} -c user.name=test -c user.email=test@example.com merge topic/one topic/two -m octopus", "."); + + using var backends = OpenBothBackends(fixture); + + backends.Managed.Head.Tip.ShouldNotBeNull().Parents.Count.ShouldBe(3); + AssertCommitLogParity(backends); + AssertMergeBaseParity(backends); + AssertDiffPathsParity(backends); + } + + [Test] + public void PackedAndLooseReferencesBehaveIdenticallyOnBothBackends() + { + using var fixture = CreateComplexFixture(); + + // Pack everything, then shadow one packed ref with a loose one and add brand-new loose refs, + // so both stores must merge packed and loose entries (and their peeled annotated-tag lines). + GitTestExtensions.ExecuteGitCmd($"-C {fixture.RepositoryPath} pack-refs --all", "."); + fixture.MakeACommit("shadows the packed main entry"); + fixture.BranchTo("loose/branch"); + fixture.ApplyTag("loose-tag"); + + using var backends = OpenBothBackends(fixture); + + static IEnumerable DescribeReferences(IGitRepository repository) => + repository.References.Select(r => $"{r.Name.Canonical} -> {r.TargetIdentifier}"); + + DescribeReferences(backends.Managed).ShouldBe(DescribeReferences(backends.LibGit2)); + + static IEnumerable DescribeTags(IGitRepository repository) => + repository.Tags.Select(t => $"{t.Name.Canonical} target:{t.TargetSha} commit:{t.Commit.Sha}"); + + DescribeTags(backends.Managed).ShouldBe(DescribeTags(backends.LibGit2)); + + static IEnumerable DescribeBranches(IGitRepository repository) => + repository.Branches.Select(b => $"{b.Name.Canonical}@{b.Tip?.Sha}"); + + DescribeBranches(backends.Managed).ShouldBe(DescribeBranches(backends.LibGit2)); + } + + [Test] + public void LightweightAndAnnotatedTagOnTheSameCommitBehaveIdenticallyOnBothBackends() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.MakeACommit("only commit"); + fixture.ApplyTag("lightweight"); + fixture.Repository.Tags.Add("annotated", fixture.Repository.Head.Tip, new LibGit2Sharp.Signature("unit test", "test@example.com", DateTimeOffset.Now), "the same commit, annotated"); + + using var backends = OpenBothBackends(fixture); + + static IEnumerable Describe(IGitRepository repository) => + repository.Tags.Select(t => $"{t.Name.Canonical} target:{t.TargetSha} commit:{t.Commit.Sha}"); + + Describe(backends.Managed).ShouldBe(Describe(backends.LibGit2)); + } + + [Test] + public void ShallowCloneBehavesIdenticallyOnBothBackends() + { + using var fixture = new RemoteRepositoryFixture(); + using var localFixture = fixture.CloneRepository(); + localFixture.MakeShallow(); + + using var backends = OpenBothBackends(localFixture); + + backends.Managed.IsShallow.ShouldBeTrue(); + backends.Managed.IsShallow.ShouldBe(backends.LibGit2.IsShallow); + AssertCommitLogParity(backends); + } + + [Test] + public void WorktreeBehavesIdenticallyOnBothBackends() + { + using var fixture = CreateComplexFixture(); + var worktreePath = FileSystemHelper.Path.GetRepositoryTempPath(); + GitTestExtensions.ExecuteGitCmd($"-C {fixture.RepositoryPath} worktree add {worktreePath} develop", "."); + + try + { + using var backends = OpenBothBackendsAt(worktreePath); + + backends.Managed.Path.ShouldBe(backends.LibGit2.Path); + backends.Managed.WorkingDirectory.ShouldBe(backends.LibGit2.WorkingDirectory); + backends.Managed.Head.Name.Canonical.ShouldBe(backends.LibGit2.Head.Name.Canonical); + (backends.Managed.Head.Tip?.Sha).ShouldBe(backends.LibGit2.Head.Tip?.Sha); + backends.Managed.UncommittedChangesCount().ShouldBe(backends.LibGit2.UncommittedChangesCount()); + + static IEnumerable DescribeReferences(IGitRepository repository) => + repository.References.Select(r => $"{r.Name.Canonical} -> {r.TargetIdentifier}"); + + DescribeReferences(backends.Managed).ShouldBe(DescribeReferences(backends.LibGit2)); + AssertCommitLogParity(backends); + } + finally + { + GitTestExtensions.ExecuteGitCmd($"-C {fixture.RepositoryPath} worktree remove --force {worktreePath}", "."); + } + } + + [Test] + public void IndexVersionFourBehavesIdenticallyOnBothBackends() + { + using var fixture = new EmptyRepositoryFixture(); + var signature = new LibGit2Sharp.Signature("unit test", "test@example.com", DateTimeOffset.Now); + + var trackedFile = FileSystemHelper.Path.Combine(fixture.RepositoryPath, "tracked.txt"); + File.WriteAllText(trackedFile, "one"); + LibGit2Sharp.Commands.Stage(fixture.Repository, "tracked.txt"); + fixture.Repository.Commit("add tracked file", signature, signature, new LibGit2Sharp.CommitOptions()); + + GitTestExtensions.ExecuteGitCmd($"-C {fixture.RepositoryPath} update-index --index-version 4", "."); + File.WriteAllText(trackedFile, "two"); + File.WriteAllText(FileSystemHelper.Path.Combine(fixture.RepositoryPath, "untracked.txt"), "new"); + + using var backends = OpenBothBackends(fixture); + backends.Managed.UncommittedChangesCount().ShouldBe(backends.LibGit2.UncommittedChangesCount()); + } + + private static void AssertCommitLogParity(BackendPair backends) + { + static IEnumerable Describe(IGitRepository repository) => + repository.Commits.Select(c => $"{c.Sha} merge:{c.IsMergeCommit} parents:{string.Join(',', c.Parents.Select(p => p.Sha))}"); + + Describe(backends.Managed).ShouldBe(Describe(backends.LibGit2)); + } + + private static void AssertMergeBaseParity(BackendPair backends) + { + var libGit2Commits = CollectAllCommits(backends.LibGit2); + var managedCommits = CollectAllCommits(backends.Managed); + + foreach (var first in libGit2Commits.Keys.Order()) + { + foreach (var second in libGit2Commits.Keys.Order()) + { + var libGit2MergeBase = backends.LibGit2.FindMergeBase(libGit2Commits[first], libGit2Commits[second]); + var managedMergeBase = backends.Managed.FindMergeBase(managedCommits[first], managedCommits[second]); + (managedMergeBase?.Sha).ShouldBe(libGit2MergeBase?.Sha, $"merge base of {first[..7]} and {second[..7]}"); + } + } + } + + private static void AssertDiffPathsParity(BackendPair backends) + { + var libGit2Commits = CollectAllCommits(backends.LibGit2); + var managedCommits = CollectAllCommits(backends.Managed); + + foreach (var sha in libGit2Commits.Keys.Order()) + { + managedCommits[sha].DiffPaths.ShouldBe(libGit2Commits[sha].DiffPaths, $"diff paths of {sha[..7]}"); + } + } + + private static RepositoryFixtureBase CreateComplexFixture() + { + var fixture = new EmptyRepositoryFixture(); + fixture.MakeACommit("initial commit"); + fixture.ApplyTag("1.0.0"); + fixture.BranchTo("develop"); + fixture.MakeACommit("develop one"); + fixture.Checkout("main"); + fixture.MakeACommit("main two"); + fixture.MergeNoFF("develop"); + fixture.Repository.Tags.Add("v2.0.0", fixture.Repository.Head.Tip, new LibGit2Sharp.Signature("unit test", "test@example.com", DateTimeOffset.Now), "an annotated tag"); + fixture.BranchTo("feature/complex"); + fixture.MakeACommit("feature one"); + fixture.Checkout("develop"); + fixture.MakeACommit("develop two"); + fixture.Checkout("main"); + return fixture; + } + + /// + /// Builds a criss-cross history (two merge commits whose parents are swapped), which is + /// the classic ambiguous merge-base scenario, plus commits with equal timestamps. + /// + private static RepositoryFixtureBase CreateCrissCrossFixture() + { + var fixture = new EmptyRepositoryFixture(); + var signature = new LibGit2Sharp.Signature("unit test", "test@example.com", DateTimeOffset.Now); + var mergeOptions = new LibGit2Sharp.MergeOptions { FastForwardStrategy = LibGit2Sharp.FastForwardStrategy.NoFastForward }; + + fixture.MakeACommit("base"); + fixture.BranchTo("develop"); + fixture.MakeACommit("develop one"); + var developHead = fixture.Repository.Head.Tip; + fixture.Checkout("main"); + fixture.MakeACommit("main one"); + var mainHead = fixture.Repository.Head.Tip; + + // main merges develop's old head while develop merges main's old head: criss-cross. + fixture.Repository.Merge(developHead, signature, mergeOptions); + fixture.Checkout("develop"); + fixture.Repository.Merge(mainHead, signature, mergeOptions); + + fixture.MakeACommit("develop two"); + fixture.Checkout("main"); + fixture.MakeACommit("main two"); + return fixture; + } + + private static BackendPair OpenBothBackends(RepositoryFixtureBase fixture) => OpenBothBackendsAt(fixture.RepositoryPath); + + private static BackendPair OpenBothBackendsAt(string repositoryPath) + { + var libGit2 = new GitRepository(NullLogger.Instance); + libGit2.DiscoverRepository(repositoryPath); + + var cliMutator = new GitCliMutator(NullLogger.Instance, new GitCliExecutor(NullLogger.Instance)); + var managed = new ManagedGitRepository(NullLogger.Instance, cliMutator); + managed.DiscoverRepository(repositoryPath); + + return new(libGit2, managed); + } + + private static Dictionary CollectAllCommits(IGitRepository repository) + { + var commits = new Dictionary(StringComparer.Ordinal); + + foreach (var branch in repository.Branches) + { + foreach (var commit in branch.Commits) + { + commits[commit.Sha] = commit; + } + } + + return commits; + } + + private sealed record BackendPair(IGitRepository LibGit2, IGitRepository Managed) : IDisposable + { + public void Dispose() + { + LibGit2.Dispose(); + Managed.Dispose(); + } + } +} diff --git a/src/GitVersion.Core.Tests/Core/GitBackendSelectorTests.cs b/src/GitVersion.Core.Tests/Core/GitBackendSelectorTests.cs new file mode 100644 index 0000000000..13ace25adf --- /dev/null +++ b/src/GitVersion.Core.Tests/Core/GitBackendSelectorTests.cs @@ -0,0 +1,46 @@ +using GitVersion.Git; + +namespace GitVersion.Tests; + +[TestFixture] +[NonParallelizable] +public class GitBackendSelectorTests : TestBase +{ + [TestCase(null, false)] + [TestCase("", false)] + [TestCase("libgit2", false)] + [TestCase("LIBGIT2", false)] + [TestCase("managed", true)] + [TestCase("Managed", true)] + [TestCase(" managed ", true)] + public void ResolvesKnownValues(string? value, bool isManaged) + { + using var scope = new EnvironmentVariableScope(value); + + GitBackendSelector.Resolve().ShouldBe(isManaged ? GitBackend.Managed : GitBackend.LibGit2); + } + + [TestCase("manged")] + [TestCase("libgit2sharp")] + [TestCase("true")] + public void FailsFastOnUnknownValues(string value) + { + // A silently ignored typo would make a user believe they validated the + // managed backend while actually running libgit2. + using var scope = new EnvironmentVariableScope(value); + + Should.Throw(() => GitBackendSelector.Resolve()) + .Message.ShouldContain(value); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string? original = System.Environment.GetEnvironmentVariable(GitBackendSelector.EnvironmentVariableName); + + public EnvironmentVariableScope(string? value) => + System.Environment.SetEnvironmentVariable(GitBackendSelector.EnvironmentVariableName, value); + + public void Dispose() => + System.Environment.SetEnvironmentVariable(GitBackendSelector.EnvironmentVariableName, this.original); + } +} diff --git a/src/GitVersion.Core.Tests/Core/GitCliMutatorTests.cs b/src/GitVersion.Core.Tests/Core/GitCliMutatorTests.cs new file mode 100644 index 0000000000..babdaccb55 --- /dev/null +++ b/src/GitVersion.Core.Tests/Core/GitCliMutatorTests.cs @@ -0,0 +1,185 @@ +using GitVersion.Git; +using GitVersion.Helpers; +using GitVersion.Testing.Extensions; +using LibGit2Sharp; + +namespace GitVersion.Tests; + +[TestFixture] +public class GitCliMutatorTests : TestBase +{ + private static GitCliMutator CreateMutator() => + new(NullLogger.Instance, new GitCliExecutor(NullLogger.Instance)); + + [Test] + public void CheckoutSwitchesToTheGivenBranch() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.Repository.MakeACommit(); + fixture.Repository.CreateBranch("feature/cli"); + + CreateMutator().Checkout(fixture.RepositoryPath, "feature/cli"); + + fixture.Repository.Head.FriendlyName.ShouldBe("feature/cli"); + } + + [Test] + public void CheckoutOfUnknownSpecThrows() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.Repository.MakeACommit(); + + Should.Throw(() => CreateMutator().Checkout(fixture.RepositoryPath, "does-not-exist")); + } + + [Test] + [NonParallelizable] + public void CheckoutIgnoresAnInheritedGitDirEnvironmentVariable() + { + // git exports GIT_DIR when running hooks; the executor must scrub it so + // mutations target the working-directory repository, as libgit2 did. + using var otherFixture = new EmptyRepositoryFixture(); + otherFixture.Repository.MakeACommit(); + + using var fixture = new EmptyRepositoryFixture(); + fixture.Repository.MakeACommit(); + fixture.Repository.CreateBranch("only-here"); + + System.Environment.SetEnvironmentVariable("GIT_DIR", FileSystemHelper.Path.Combine(otherFixture.RepositoryPath, ".git")); + try + { + CreateMutator().Checkout(fixture.RepositoryPath, "only-here"); + } + finally + { + System.Environment.SetEnvironmentVariable("GIT_DIR", null); + } + + fixture.Repository.Head.FriendlyName.ShouldBe("only-here"); + } + + [Test] + public void CheckoutFailureIsNotMisclassifiedAsAnHttpError() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.Repository.MakeACommit(); + + // The failing spec echoes "404" in stderr; only network operations may map + // HTTP status codes, so the real pathspec error must surface unchanged. + var exception = Should.Throw( + () => CreateMutator().Checkout(fixture.RepositoryPath, "fix-404-page")); + + exception.Message.ShouldContain("fix-404-page"); + exception.Message.ShouldNotContain("The repository was not found"); + } + + [Test] + public void FetchBringsNewRemoteCommitsIntoTheLocalRepository() + { + using var fixture = new RemoteRepositoryFixture(); + var newCommit = fixture.Repository.MakeACommit(); + var localRepository = fixture.LocalRepositoryFixture.Repository; + localRepository.Lookup(newCommit.Sha).ShouldBeNull(); + + CreateMutator().Fetch(fixture.LocalRepositoryFixture.RepositoryPath, "origin", [], new AuthenticationInfo()); + + localRepository.Lookup(newCommit.Sha).ShouldNotBeNull(); + } + + [Test] + public void CloneCreatesRepositoryWithoutCheckingOutTheWorkingDirectory() + { + using var fixture = new EmptyRepositoryFixture(); + fixture.Repository.MakeACommit(); + var targetPath = FileSystemHelper.Path.GetRepositoryTempPath(); + + try + { + CreateMutator().Clone(fixture.RepositoryPath, targetPath, new AuthenticationInfo()); + + using var clonedRepository = new Repository(targetPath); + clonedRepository.Head.Tip.ShouldNotBeNull(); + Directory.EnumerateFileSystemEntries(targetPath) + .Where(entry => FileSystemHelper.Path.GetFileName(entry) != ".git") + .ShouldBeEmpty(); + } + finally + { + FileSystemHelper.Directory.DeleteDirectory(targetPath); + } + } + + [Test] + public void CloneOfMissingRepositoryThrows() + { + var missingSource = FileSystemHelper.Path.Combine(FileSystemHelper.Path.GetTempPathLegacy(), "gitversion-does-not-exist"); + var targetPath = FileSystemHelper.Path.GetRepositoryTempPath(); + + Should.Throw(() => CreateMutator().Clone(missingSource, targetPath, new AuthenticationInfo())); + } + + [Test] + public void CloneOfMissingHttpRepositoryMapsGitsNotFoundPhrasing() + { + // Non-GitHub hosts produce "fatal: repository '' not found" (no "404", + // no contiguous "Repository not found"); the message contract must still hold. + using var server = new HttpNotFoundServer(); + var targetPath = FileSystemHelper.Path.GetRepositoryTempPath(); + + var exception = Should.Throw( + () => CreateMutator().Clone($"{server.Url}/missing/repo.git", targetPath, new AuthenticationInfo())); + + exception.Message.ShouldBe("Not found: The repository was not found"); + } + + private sealed class HttpNotFoundServer : IDisposable + { + private readonly System.Net.HttpListener listener = new(); + public string Url { get; } + + public HttpNotFoundServer() + { + var port = GetFreePort(); + Url = $"http://127.0.0.1:{port}"; + this.listener.Prefixes.Add($"{Url}/"); + this.listener.Start(); + _ = Task.Run(async () => + { + while (this.listener.IsListening) + { + try + { + var context = await this.listener.GetContextAsync().ConfigureAwait(false); + context.Response.StatusCode = 404; + context.Response.Close(); + } + catch (Exception ex) when (ex is System.Net.HttpListenerException or ObjectDisposedException) + { + return; + } + } + }); + } + + private static int GetFreePort() + { + using var socket = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + socket.Start(); + return ((System.Net.IPEndPoint)socket.LocalEndpoint).Port; + } + + public void Dispose() => this.listener.Stop(); + } + + [Test] + public void ListRemoteReferencesReturnsBranchTips() + { + using var fixture = new RemoteRepositoryFixture(); + var remoteTipSha = fixture.Repository.Head.Tip.Sha; + + var references = CreateMutator().ListRemoteReferences(fixture.LocalRepositoryFixture.RepositoryPath, "origin", new AuthenticationInfo()); + + references.Single(r => r.CanonicalName == "refs/heads/main").TargetSha.ShouldBe(remoteTipSha); + references.ShouldNotContain(r => r.CanonicalName == "HEAD"); + } +} diff --git a/src/GitVersion.Core.Tests/GitVersion.Core.Tests.csproj b/src/GitVersion.Core.Tests/GitVersion.Core.Tests.csproj index 7d0ca0d4df..99d378847f 100644 --- a/src/GitVersion.Core.Tests/GitVersion.Core.Tests.csproj +++ b/src/GitVersion.Core.Tests/GitVersion.Core.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/GitVersion.Core.Tests/Helpers/GitVersionCoreTestModule.cs b/src/GitVersion.Core.Tests/Helpers/GitVersionCoreTestModule.cs index ec79c489ce..4067886393 100644 --- a/src/GitVersion.Core.Tests/Helpers/GitVersionCoreTestModule.cs +++ b/src/GitVersion.Core.Tests/Helpers/GitVersionCoreTestModule.cs @@ -2,6 +2,7 @@ using GitVersion.Agents; using GitVersion.Configuration; using GitVersion.Extensions; +using GitVersion.Git; using GitVersion.Output; namespace GitVersion.Tests; @@ -10,7 +11,9 @@ public class GitVersionCoreTestModule : IGitVersionModule { public void RegisterTypes(IServiceCollection services) { - services.AddModule(new GitVersionLibGit2SharpModule()); + services.AddModule(GitBackendSelector.Resolve() == GitBackend.Managed + ? new GitVersionManagedGitModule() + : new GitVersionLibGit2SharpModule()); services.AddModule(new GitVersionBuildAgentsModule()); services.AddModule(new GitVersionOutputModule()); services.AddModule(new GitVersionConfigurationModule()); diff --git a/src/GitVersion.Core/Git/GitBackend.cs b/src/GitVersion.Core/Git/GitBackend.cs new file mode 100644 index 0000000000..3941a1095c --- /dev/null +++ b/src/GitVersion.Core/Git/GitBackend.cs @@ -0,0 +1,53 @@ +namespace GitVersion.Git; + +/// +/// The Git backend implementations selectable via the GITVERSION_GIT_BACKEND +/// environment variable during the dual-backend window (v7.0–v7.x). +/// +internal enum GitBackend +{ + /// The libgit2-based backend (the v7.0 default). + LibGit2, + + /// The managed reader + git CLI mutator backend. + Managed +} + +/// +/// The single place where GITVERSION_GIT_BACKEND is interpreted: every composition +/// root selects the backend through this class so production and tests cannot drift, and +/// the v7.1 default flip is a one-line change. This is configuration, not Git logic — it +/// names no backend internals, so it lives at the composition seam that every root already +/// shares rather than inside one of the backend assemblies. +/// +internal static class GitBackendSelector +{ + public const string EnvironmentVariableName = "GITVERSION_GIT_BACKEND"; + + /// + /// The canonical configuration name of the resolved backend, as users set it + /// in GITVERSION_GIT_BACKEND: libgit2 or managed. + /// + public static string ResolveName() => Resolve() == GitBackend.Managed ? "managed" : "libgit2"; + + /// + /// Resolves the backend from the environment: libgit2 (the default when unset) + /// or managed. Unknown values fail fast — a silently ignored typo would make a + /// user believe they validated the managed backend while running libgit2. + /// + /// The selected backend. + /// The variable is set to an unrecognized value. + public static GitBackend Resolve() + { + var value = SysEnv.GetEnvironmentVariable(EnvironmentVariableName)?.Trim(); + + return value switch + { + null or "" => GitBackend.LibGit2, + _ when value.Equals("libgit2", StringComparison.OrdinalIgnoreCase) => GitBackend.LibGit2, + _ when value.Equals("managed", StringComparison.OrdinalIgnoreCase) => GitBackend.Managed, + _ => throw new InvalidOperationException( + $"Unrecognized {EnvironmentVariableName} value '{value}'. Valid values are 'libgit2' and 'managed'.") + }; + } +} diff --git a/src/GitVersion.Core/GitVersion.Core.csproj b/src/GitVersion.Core/GitVersion.Core.csproj index 233622e080..50c670ac11 100644 --- a/src/GitVersion.Core/GitVersion.Core.csproj +++ b/src/GitVersion.Core/GitVersion.Core.csproj @@ -30,6 +30,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/src/GitVersion.Git.Managed.Tests/AssemblyParallelizable.cs b/src/GitVersion.Git.Managed.Tests/AssemblyParallelizable.cs new file mode 100644 index 0000000000..fdd365b7e2 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/AssemblyParallelizable.cs @@ -0,0 +1 @@ +[assembly: Parallelizable(ParallelScope.Fixtures)] diff --git a/src/GitVersion.Git.Managed.Tests/DeltaStreamReaderTests.cs b/src/GitVersion.Git.Managed.Tests/DeltaStreamReaderTests.cs new file mode 100644 index 0000000000..c1b2a53a21 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/DeltaStreamReaderTests.cs @@ -0,0 +1,37 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class DeltaStreamReaderTests +{ + [Test] + public void ReadsACompleteCopyInstruction() + { + // Copy with one offset byte (0x42) and one size byte (0x07). + using var stream = new MemoryStream([0b1001_0001, 0x42, 0x07]); + + var instruction = DeltaStreamReader.Read(stream); + + instruction.ShouldNotBeNull(); + instruction.Value.InstructionType.ShouldBe(DeltaInstructionType.Copy); + instruction.Value.Offset.ShouldBe(0x42); + instruction.Value.Size.ShouldBe(0x07); + } + + [Test] + public void ReturnsNullAtTheEndOfTheStream() + { + using var stream = new MemoryStream([]); + + DeltaStreamReader.Read(stream).ShouldBeNull(); + } + + [Test] + public void ThrowsWhenTheStreamEndsInsideACopyInstruction() + { + // The instruction announces an offset byte and a size byte, but the stream is + // truncated: EOF must not be silently decoded as 0xFF (garbage copy target). + using var stream = new MemoryStream([0b1001_0001, 0x42]); + + Should.Throw(() => DeltaStreamReader.Read(stream)); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitConfigurationFileTests.cs b/src/GitVersion.Git.Managed.Tests/GitConfigurationFileTests.cs new file mode 100644 index 0000000000..3e0f114d77 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitConfigurationFileTests.cs @@ -0,0 +1,70 @@ +using GitVersion.Helpers; + +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class GitConfigurationFileTests +{ + private static GitConfigurationFile Load(string content) + { + var directory = FileSystemHelper.Path.GetRepositoryTempPath(); + Directory.CreateDirectory(directory); + var path = FileSystemHelper.Path.Combine(directory, "config"); + File.WriteAllText(path, content); + try + { + return GitConfigurationFile.Load(path); + } + finally + { + FileSystemHelper.Directory.DeleteDirectory(directory); + } + } + + [Test] + public void UnescapesBackslashesInUnquotedValue() + { + // git stores a Windows remote path with escaped backslashes and no surrounding quotes. + var config = Load("[remote \"origin\"]\n\turl = D:\\\\a\\\\_temp\\\\repo\n"); + + config.GetString("remote", "origin", "url").ShouldBe(@"D:\a\_temp\repo"); + } + + [Test] + public void UnescapesBackslashesInQuotedValue() + { + var config = Load("[remote \"origin\"]\n\turl = \"D:\\\\a\\\\repo\"\n"); + + config.GetString("remote", "origin", "url").ShouldBe(@"D:\a\repo"); + } + + [Test] + public void KeepsForwardSlashPathUnchanged() + { + var config = Load("[remote \"origin\"]\n\turl = /srv/git/repositories/project.git\n"); + + config.GetString("remote", "origin", "url").ShouldBe("/srv/git/repositories/project.git"); + } + + [Test] + public void StripsInlineCommentOutsideQuotesButNotInside() + { + Load("[core]\n\tx = value ; trailing\n").GetString("core", null, "x").ShouldBe("value"); + Load("[core]\n\tx = \"a ; b\"\n").GetString("core", null, "x").ShouldBe("a ; b"); + } + + [Test] + public void DecodesEscapeSequences() + { + var config = Load("[core]\n\tx = a\\tb\\nc\n"); + + config.GetString("core", null, "x").ShouldBe("a\tb\nc"); + } + + [Test] + public void TrimsUnquotedTrailingWhitespaceButKeepsQuotedWhitespace() + { + Load("[core]\n\tx = value \n").GetString("core", null, "x").ShouldBe("value"); + Load("[core]\n\tx = \"value \"\n").GetString("core", null, "x").ShouldBe("value "); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitIndexTests.cs b/src/GitVersion.Git.Managed.Tests/GitIndexTests.cs new file mode 100644 index 0000000000..d2730f3c5d --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitIndexTests.cs @@ -0,0 +1,96 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class GitIndexTests +{ + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + public void IndexEntriesMatchGitLsFiles(int indexVersion) + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.WriteFile("sub/b.txt", "two\n"); + repository.WriteFile("sub/deep/c.txt", "three\n"); + repository.Commit("commit"); + repository.Run("update-index", "--chmod=+x", "a.txt"); + + if (indexVersion == 3) + { + // Git only writes version 3 when an entry actually carries extended flags. + repository.Run("update-index", "--skip-worktree", "sub/b.txt"); + } + + repository.Run("update-index", $"--index-version={indexVersion}"); + + var index = GitIndex.Read(Path.Combine(repository.GitDirectory, "index")); + + index.Version.ShouldBe(indexVersion); + + // git ls-files --stage prints: SP SP TAB + var expected = repository + .Run("ls-files", "--stage") + .Split('\n') + .Select(line => + { + var parts = line.Split('\t'); + var meta = parts[0].Split(' '); + return (Mode: Convert.ToUInt32(meta[0], 8), Sha: meta[1], Stage: int.Parse(meta[2]), Path: parts[1]); + }) + .ToList(); + + index.Entries.Count.ShouldBe(expected.Count); + + for (var i = 0; i < expected.Count; i++) + { + var entry = index.Entries[i]; + entry.Path.ShouldBe(expected[i].Path); + entry.Mode.ShouldBe(expected[i].Mode); + entry.ObjectId.ToString().ShouldBe(expected[i].Sha); + entry.Stage.ShouldBe(expected[i].Stage); + } + + index.Entries.Single(entry => entry.Path == "a.txt").IsExecutable.ShouldBeTrue(); + index.Entries.Single(entry => entry.Path == "sub/b.txt").IsExecutable.ShouldBeFalse(); + } + + [Test] + public void ReadsStatDataUsedForCleanDetection() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "content\n"); + repository.Commit("commit"); + + var index = GitIndex.Read(Path.Combine(repository.GitDirectory, "index")); + var entry = index.Entries.ShouldHaveSingleItem(); + + entry.Size.ShouldBe((uint)"content\n".Length); + entry.ModificationTimeSeconds.ShouldBeGreaterThan(0u); + entry.AssumeValid.ShouldBeFalse(); + entry.SkipWorktree.ShouldBeFalse(); + entry.IntentToAdd.ShouldBeFalse(); + } + + [Test] + public void ReadsSkipWorktreeExtendedFlags() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.WriteFile("b.txt", "two\n"); + repository.Commit("commit"); + repository.Run("update-index", "--skip-worktree", "a.txt"); + + var index = GitIndex.Read(Path.Combine(repository.GitDirectory, "index")); + + index.Entries.Single(entry => entry.Path == "a.txt").SkipWorktree.ShouldBeTrue(); + index.Entries.Single(entry => entry.Path == "b.txt").SkipWorktree.ShouldBeFalse(); + } + + [Test] + public void AMissingIndexFileYieldsAnEmptyIndex() + { + var index = GitIndex.Read(Path.Combine(Path.GetTempPath(), "does-not-exist", "index")); + + index.Entries.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitObjectIdTests.cs b/src/GitVersion.Git.Managed.Tests/GitObjectIdTests.cs new file mode 100644 index 0000000000..61cf66bd47 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitObjectIdTests.cs @@ -0,0 +1,160 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class GitObjectIdTests +{ + private const string Sha1Hex = "4e912736c27e40b389904d046dc63dc9f578117f"; + private const string Sha256Hex = "9c5e01e5827b7a2e0f9b2ecabf5ee89f4fd8b7c153bf8dd9b1f38f9b7d6f2a3c"; + + [Test] + public void ParsesA40CharacterSha1HexString() + { + var objectId = GitObjectId.Parse(Sha1Hex); + + objectId.HashLength.ShouldBe(GitObjectId.Sha1Size); + objectId.ToString().ShouldBe(Sha1Hex); + } + + [Test] + public void ParsesUpperCaseHexAndNormalizesToLowerCase() + { + var objectId = GitObjectId.Parse(Sha1Hex.ToUpperInvariant().AsSpan()); + + objectId.ToString().ShouldBe(Sha1Hex); + } + + [Test] + public void ParsesA64CharacterSha256HexString() + { + var objectId = GitObjectId.Parse(Sha256Hex); + + objectId.HashLength.ShouldBe(GitObjectId.Sha256Size); + objectId.ToString().ShouldBe(Sha256Hex); + } + + [Test] + public void ParsesRawBytes() + { + var bytes = Convert.FromHexString(Sha1Hex); + + var objectId = GitObjectId.Parse(bytes); + + objectId.HashLength.ShouldBe(GitObjectId.Sha1Size); + objectId.ToString().ShouldBe(Sha1Hex); + } + + [Test] + public void ParsesAsciiEncodedHex() + { + var asciiHex = Encoding.ASCII.GetBytes(Sha1Hex); + + var objectId = GitObjectId.ParseHex(asciiHex); + + objectId.ToString().ShouldBe(Sha1Hex); + } + + [TestCase(0)] + [TestCase(19)] + [TestCase(39)] + [TestCase(41)] + public void ParseRejectsInvalidHexLengths(int length) => + Should.Throw(() => GitObjectId.Parse(new string('a', length))); + + [Test] + public void ParseRejectsInvalidRawByteLengths() => + Should.Throw(() => GitObjectId.Parse(new byte[21])); + + [Test] + public void ParseRejectsNonHexCharacters() => + Should.Throw(() => GitObjectId.Parse("zz12736c27e40b389904d046dc63dc9f578117f4")); + + [Test] + public void EqualObjectIdsAreEqualAndHaveTheSameHashCode() + { + var left = GitObjectId.Parse(Sha1Hex); + var right = GitObjectId.ParseHex(Encoding.ASCII.GetBytes(Sha1Hex)); + + left.ShouldBe(right); + (left == right).ShouldBeTrue(); + (left != right).ShouldBeFalse(); + left.GetHashCode().ShouldBe(right.GetHashCode()); + left.Equals((object)right).ShouldBeTrue(); + } + + [Test] + public void DifferentObjectIdsAreNotEqual() + { + var left = GitObjectId.Parse(Sha1Hex); + var right = GitObjectId.Parse("4e912736c27e40b389904d046dc63dc9f5781180"); + + left.ShouldNotBe(right); + (left == right).ShouldBeFalse(); + (left != right).ShouldBeTrue(); + } + + [Test] + public void ASha1IdIsNotEqualToASha256IdWithTheSamePrefix() + { + var sha1 = GitObjectId.Parse(Sha1Hex); + var sha256 = GitObjectId.Parse(Sha1Hex + new string('0', 24)); + + sha1.ShouldNotBe(sha256); + } + + [Test] + public void CanBeUsedAsADictionaryKey() + { + var dictionary = new Dictionary + { + [GitObjectId.Parse(Sha1Hex)] = "value" + }; + + dictionary[GitObjectId.ParseHex(Encoding.ASCII.GetBytes(Sha1Hex))].ShouldBe("value"); + } + + [TestCase(0, "")] + [TestCase(1, "4")] + [TestCase(7, "4e91273")] + [TestCase(40, Sha1Hex)] + public void ToStringWithLengthReturnsAHexPrefix(int length, string expected) + { + var objectId = GitObjectId.Parse(Sha1Hex); + + objectId.ToString(length).ShouldBe(expected); + } + + [Test] + public void ToStringWithInvalidLengthThrows() + { + var objectId = GitObjectId.Parse(Sha1Hex); + + Should.Throw(() => objectId.ToString(41)); + Should.Throw(() => objectId.ToString(-1)); + } + + [Test] + public void CopyToCopiesTheRawBytes() + { + var objectId = GitObjectId.Parse(Sha1Hex); + var buffer = new byte[GitObjectId.Sha1Size]; + + objectId.CopyTo(buffer); + + buffer.ShouldBe(Convert.FromHexString(Sha1Hex)); + } + + [Test] + public void AsUInt16ReturnsTheFirstTwoBytes() + { + var objectId = GitObjectId.Parse(Sha1Hex); + + objectId.AsUInt16().ShouldBe((ushort)0x4e91); + } + + [Test] + public void EmptyHasAZeroHashLength() + { + GitObjectId.Empty.HashLength.ShouldBe(0); + GitObjectId.Empty.ShouldBe(default(GitObjectId)); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitReferenceStoreTests.cs b/src/GitVersion.Git.Managed.Tests/GitReferenceStoreTests.cs new file mode 100644 index 0000000000..5c1d940468 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitReferenceStoreTests.cs @@ -0,0 +1,216 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class GitReferenceStoreTests +{ + [Test] + public void ResolvesALooseBranchToItsCommit() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + var store = repository.OpenReferenceStore(); + var reference = store.GetReference("refs/heads/main"); + + reference.ShouldNotBeNull(); + reference.IsSymbolic.ShouldBeFalse(); + reference.ObjectId.ShouldBe(GitObjectId.Parse(sha)); + store.ResolveToObjectId("refs/heads/main").ShouldBe(GitObjectId.Parse(sha)); + } + + [Test] + public void EnumerationMatchesGitForEachRef() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("first"); + repository.Run("branch", "feature/a"); + repository.Run("tag", "lightweight"); + repository.Run("tag", "-a", "v1.0.0", "-m", "annotated"); + repository.WriteFile("file.txt", "more content\n"); + repository.Commit("second"); + + var store = repository.OpenReferenceStore(); + var actual = store.EnumerateReferences() + .Select(reference => $"{reference.CanonicalName} {reference.ObjectId}") + .ToList(); + + var expected = repository + .Run("for-each-ref", "--format=%(refname) %(objectname)") + .Split('\n'); + + actual.ShouldBe(expected); + } + + [Test] + public void EnumerationSkipsTransientLockFiles() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + // Simulate git being in the middle of updating a reference. + File.WriteAllText(Path.Combine(repository.GitDirectory, "refs", "heads", "main.lock"), sha + "\n"); + + var references = repository.OpenReferenceStore().EnumerateReferences().ToList(); + + references.ShouldNotContain(reference => reference.CanonicalName.EndsWith(".lock")); + references.ShouldHaveSingleItem().CanonicalName.ShouldBe("refs/heads/main"); + } + + [Test] + public void ReadsPackedRefsIncludingPeeledTargets() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var commitSha = repository.Commit("a commit"); + repository.Run("tag", "-a", "v1.0.0", "-m", "annotated"); + var tagSha = repository.RevParse("v1.0.0"); + + repository.Run("pack-refs", "--all", "--prune"); + File.Exists(Path.Combine(repository.GitDirectory, "refs", "heads", "main")).ShouldBeFalse(); + + var store = repository.OpenReferenceStore(); + + store.ResolveToObjectId("refs/heads/main").ShouldBe(GitObjectId.Parse(commitSha)); + + var tag = store.GetReference("refs/tags/v1.0.0"); + tag.ShouldNotBeNull(); + tag.ObjectId.ShouldBe(GitObjectId.Parse(tagSha)); + tag.PeeledObjectId.ShouldBe(GitObjectId.Parse(repository.RevParse("v1.0.0^{}"))); + } + + [Test] + public void LooseReferencesWinOverPackedReferences() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("first"); + repository.Run("pack-refs", "--all", "--prune"); + + repository.WriteFile("file.txt", "more content\n"); + var newSha = repository.Commit("second"); + + var store = repository.OpenReferenceStore(); + + store.ResolveToObjectId("refs/heads/main").ShouldBe(GitObjectId.Parse(newSha)); + store.EnumerateReferences("refs/heads/") + .ShouldHaveSingleItem() + .ObjectId.ShouldBe(GitObjectId.Parse(newSha)); + } + + [Test] + public void HeadIsSymbolicWhenABranchIsCheckedOut() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + var store = repository.OpenReferenceStore(); + var head = store.GetHead(); + + head.ShouldNotBeNull(); + head.IsSymbolic.ShouldBeTrue(); + head.SymbolicTargetName.ShouldBe("refs/heads/main"); + + var resolved = store.Resolve("HEAD"); + resolved.ShouldNotBeNull(); + resolved.CanonicalName.ShouldBe("refs/heads/main"); + resolved.ObjectId.ShouldBe(GitObjectId.Parse(sha)); + } + + [Test] + public void HeadIsDirectWhenDetached() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + repository.Run("checkout", "-q", "--detach"); + + var head = repository.OpenReferenceStore().GetHead(); + + head.ShouldNotBeNull(); + head.IsSymbolic.ShouldBeFalse(); + head.ObjectId.ShouldBe(GitObjectId.Parse(sha)); + } + + [Test] + public void HeadOfAnUnbornBranchResolvesToNull() + { + using var repository = new GitTestRepository(); + + var store = repository.OpenReferenceStore(); + + store.GetHead().ShouldNotBeNull().IsSymbolic.ShouldBeTrue(); + store.Resolve("HEAD").ShouldBeNull(); + store.ResolveToObjectId("HEAD").ShouldBeNull(); + } + + [Test] + public void FollowsChainsOfSymbolicReferences() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + repository.Run("symbolic-ref", "refs/sym/one", "refs/heads/main"); + repository.Run("symbolic-ref", "refs/sym/two", "refs/sym/one"); + + var store = repository.OpenReferenceStore(); + var resolved = store.Resolve("refs/sym/two"); + + resolved.ShouldNotBeNull(); + resolved.CanonicalName.ShouldBe("refs/heads/main"); + resolved.ObjectId.ShouldBe(GitObjectId.Parse(sha)); + } + + [Test] + public void ReturnsNullForAMissingReference() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("a commit"); + + var store = repository.OpenReferenceStore(); + + store.GetReference("refs/heads/does-not-exist").ShouldBeNull(); + store.Resolve("refs/heads/does-not-exist").ShouldBeNull(); + } + + [Test] + public void EnumerationCanBeFilteredByPrefix() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("a commit"); + repository.Run("branch", "feature/a"); + repository.Run("tag", "v1.0.0"); + repository.Run("tag", "v2.0.0"); + + var store = repository.OpenReferenceStore(); + + store.EnumerateReferences("refs/tags/") + .Select(reference => reference.CanonicalName) + .ShouldBe(["refs/tags/v1.0.0", "refs/tags/v2.0.0"]); + + store.EnumerateReferences("refs/heads/") + .Select(reference => reference.CanonicalName) + .ShouldBe(["refs/heads/feature/a", "refs/heads/main"]); + } + + [Test] + public void ResolvedHeadCanBeReadFromTheObjectStore() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("readable through refs"); + + var store = repository.OpenReferenceStore(); + var headId = store.ResolveToObjectId("HEAD"); + + headId.ShouldNotBeNull(); + + using var objectStore = repository.OpenObjectStore(); + objectStore.GetCommit(headId.Value).Message.ShouldBe("readable through refs\n"); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitRepositoryLayoutTests.cs b/src/GitVersion.Git.Managed.Tests/GitRepositoryLayoutTests.cs new file mode 100644 index 0000000000..ba701934a0 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitRepositoryLayoutTests.cs @@ -0,0 +1,165 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class GitRepositoryLayoutTests +{ + [Test] + public void DiscoversARepositoryFromItsRootAndFromANestedDirectory() + { + using var repository = new GitTestRepository(); + repository.WriteFile("sub/dir/file.txt", "content\n"); + repository.Commit("a commit"); + + foreach (var startPath in new[] { repository.RepositoryPath, Path.Combine(repository.RepositoryPath, "sub", "dir") }) + { + var layout = GitRepositoryLayout.Discover(startPath); + + layout.GitDirectory.ShouldBe(Path.GetFullPath(repository.GitDirectory)); + layout.CommonDirectory.ShouldBe(layout.GitDirectory); + layout.WorkingDirectory.ShouldBe(Path.GetFullPath(repository.RepositoryPath)); + layout.ObjectsDirectory.ShouldBe(Path.GetFullPath(repository.ObjectsDirectory)); + layout.IsShallow.ShouldBeFalse(); + } + } + + [Test] + public void TryOpenDoesNotWalkUpToAnEnclosingRepository() + { + using var repository = new GitTestRepository(); + repository.WriteFile("nested/dir/file.txt", "content\n"); + repository.Commit("a commit"); + + var nested = Path.Combine(repository.RepositoryPath, "nested", "dir"); + + GitRepositoryLayout.TryOpen(nested).ShouldBeNull(); + GitRepositoryLayout.TryOpen(repository.RepositoryPath).ShouldNotBeNull(); + GitRepositoryLayout.TryOpen(repository.GitDirectory).ShouldNotBeNull(); + } + + [Test] + [SuppressMessage("Minor Vulnerability", "S4036:Searching OS commands in PATH is security-sensitive", Justification = "git is deliberately resolved from the PATH, exactly like the production CLI executor and the other test fixtures.")] + public void RejectsSha256RepositoriesWithAClearMessage() + { + using var directory = new TempDirectory(); + Directory.CreateDirectory(directory.FullPath); + + using var process = Process.Start(new ProcessStartInfo + { + FileName = "git", + ArgumentList = { "init", "-q", "--object-format=sha256", directory.FullPath }, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + process!.WaitForExit(); + process.ExitCode.ShouldBe(0, "git init --object-format=sha256 should succeed"); + + var exception = Should.Throw(() => GitRepositoryLayout.Discover(directory.FullPath)); + exception.Message.ShouldContain("sha256"); + } + + [Test] + public void ReturnsNullOutsideOfARepository() + { + using var directory = new TempDirectory(); + Directory.CreateDirectory(directory.FullPath); + + GitRepositoryLayout.TryDiscover(directory.FullPath).ShouldBeNull(); + Should.Throw(() => GitRepositoryLayout.Discover(directory.FullPath)); + } + + [Test] + public void ResolvesLinkedWorktrees() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + using var worktree = new TempDirectory(); + repository.Run("worktree", "add", "-q", "-b", "wt-branch", worktree.FullPath); + + var layout = GitRepositoryLayout.Discover(worktree.FullPath); + + layout.WorkingDirectory.ShouldBe(Path.GetFullPath(worktree.FullPath)); + layout.GitDirectory.ShouldContain(Path.Combine(".git", "worktrees")); + layout.CommonDirectory.ShouldBe(Path.GetFullPath(repository.GitDirectory)); + + // HEAD is per-worktree; the refs are shared with the main repository. + var store = layout.CreateReferenceStore(); + var head = store.GetHead(); + head.ShouldNotBeNull(); + head.SymbolicTargetName.ShouldBe("refs/heads/wt-branch"); + store.ResolveToObjectId("HEAD").ShouldBe(GitObjectId.Parse(sha)); + store.EnumerateReferences("refs/heads/") + .Select(reference => reference.CanonicalName) + .ShouldBe(["refs/heads/main", "refs/heads/wt-branch"]); + + using var objectStore = layout.CreateObjectStore(); + objectStore.GetCommit(GitObjectId.Parse(sha)).Message.ShouldBe("a commit\n"); + } + + [Test] + public void DetectsShallowClones() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "one\n"); + repository.Commit("first"); + repository.WriteFile("file.txt", "two\n"); + var tipSha = repository.Commit("second"); + + using var clone = new TempDirectory(); + repository.Run("clone", "-q", "--depth", "1", "file://" + repository.RepositoryPath, clone.FullPath); + + var layout = GitRepositoryLayout.Discover(clone.FullPath); + + layout.IsShallow.ShouldBeTrue(); + var shallowCommits = layout.ReadShallowCommits(); + shallowCommits.ShouldHaveSingleItem().ShouldBe(GitObjectId.Parse(tipSha)); + + // The boundary commit object still records its parent (the shallow file, not the + // object, cuts the history), but the parent object is absent from the clone. + using var objectStore = layout.CreateObjectStore(); + var boundary = objectStore.GetCommit(shallowCommits[0]); + boundary.Parents.ShouldHaveSingleItem(); + objectStore.TryGetObject(boundary.Parents[0], "commit", out _).ShouldBeFalse(); + } + + [Test] + public void DiscoversABareRepository() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + using var bare = new TempDirectory(); + repository.Run("clone", "-q", "--bare", "file://" + repository.RepositoryPath, bare.FullPath); + + var layout = GitRepositoryLayout.Discover(bare.FullPath); + + layout.WorkingDirectory.ShouldBeNull(); + layout.GitDirectory.ShouldBe(Path.GetFullPath(bare.FullPath)); + layout.CommonDirectory.ShouldBe(layout.GitDirectory); + + layout.CreateReferenceStore().ResolveToObjectId("refs/heads/main").ShouldBe(GitObjectId.Parse(sha)); + } + + [Test] + public void ThrowsForReftableRepositories() + { + using var directory = new TempDirectory(); + + using var repository = new GitTestRepository(); + + try + { + repository.Run("init", "-q", "--ref-format=reftable", "-b", "main", directory.FullPath); + } + catch (InvalidOperationException) + { + Assert.Ignore("The installed git version does not support the reftable ref format."); + } + + Should.Throw(() => GitRepositoryLayout.Discover(directory.FullPath)); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitRevisionWalkerTests.cs b/src/GitVersion.Git.Managed.Tests/GitRevisionWalkerTests.cs new file mode 100644 index 0000000000..32b584c5e5 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitRevisionWalkerTests.cs @@ -0,0 +1,427 @@ +using LibGit2Sharp; + +namespace GitVersion.Git.Managed.Tests; + +/// +/// Validates ordering and merge-base results against libgit2 +/// (via LibGit2Sharp) on the same fixture repositories — the parity GitVersion's version +/// calculation depends on. +/// +[TestFixture] +public class GitRevisionWalkerTests +{ + private static readonly string[] SortCombinations = + [ + "None", + "Time", + "Topological", + "Topological, Time", + "Topological, Reverse", + "Time, Reverse", + "Topological, Time, Reverse" + ]; + + [TestCaseSource(nameof(SortCombinations))] + public void LinearHistoryMatchesLibGit2(string sortName) + { + var sort = Enum.Parse(sortName); + using var repository = CreateLinearRepository(); + + AssertWalkParity(repository, new() { Sort = sort }, head: true); + } + + [TestCaseSource(nameof(SortCombinations))] + public void MergedHistoryMatchesLibGit2(string sortName) + { + var sort = Enum.Parse(sortName); + using var repository = CreateMergedRepository(); + + AssertWalkParity(repository, new() { Sort = sort }, head: true); + } + + [TestCaseSource(nameof(SortCombinations))] + public void MergedHistoryWithExcludesMatchesLibGit2(string sortName) + { + var sort = Enum.Parse(sortName); + using var repository = CreateMergedRepository(); + var excluded = repository.RevParse("v0"); + + var options = new GitRevisionWalkOptions { Sort = sort }; + options.Include.Add(repository.ResolveId("HEAD")); + options.Exclude.Add(GitObjectId.Parse(excluded)); + + AssertWalkParity(repository, options, head: false, excludeSha: excluded); + } + + [TestCaseSource(nameof(SortCombinations))] + public void EqualCommitterTimestampsMatchLibGit2(string sortName) + { + var sort = Enum.Parse(sortName); + using var repository = CreateEqualTimestampRepository(); + + AssertWalkParity(repository, new() { Sort = sort }, head: true); + } + + [TestCaseSource(nameof(SortCombinations))] + public void CrissCrossHistoryMatchesLibGit2(string sortName) + { + var sort = Enum.Parse(sortName); + using var repository = CreateCrissCrossRepository(); + + AssertWalkParity(repository, new() { Sort = sort }, head: true); + } + + [TestCaseSource(nameof(SortCombinations))] + public void OctopusMergeMatchesLibGit2(string sortName) + { + var sort = Enum.Parse(sortName); + using var repository = CreateOctopusRepository(); + + AssertWalkParity(repository, new() { Sort = sort }, head: true); + } + + [TestCase(false)] + [TestCase(true)] + public void FirstParentWalksMatchLibGit2(bool withExclude) + { + using var repository = CreateMergedRepository(); + + var options = new GitRevisionWalkOptions { FirstParentOnly = true }; + options.Include.Add(repository.ResolveId("HEAD")); + string? excluded = null; + + if (withExclude) + { + excluded = repository.RevParse("v0"); + options.Exclude.Add(GitObjectId.Parse(excluded)); + } + + AssertWalkParity(repository, options, head: false, excludeSha: excluded); + } + + [Test] + public void MultipleIncludesMatchLibGit2() + { + using var repository = CreateMergedRepository(); + // Include two historic points rather than the merged tip. + var first = repository.RevParse("HEAD^1^"); + var second = repository.RevParse("HEAD^2"); + + var options = new GitRevisionWalkOptions { Sort = GitRevisionSortStrategies.Topological | GitRevisionSortStrategies.Time }; + options.Include.Add(GitObjectId.Parse(first)); + options.Include.Add(GitObjectId.Parse(second)); + + using var store = repository.OpenObjectStore(); + var actual = new GitRevisionWalker(store).Walk(options).Select(commit => commit.Sha.ToString()).ToList(); + + using var libgit2 = new Repository(repository.RepositoryPath); + var filter = new global::LibGit2Sharp.CommitFilter + { + IncludeReachableFrom = new[] { first, second }, + SortBy = global::LibGit2Sharp.CommitSortStrategies.Topological | global::LibGit2Sharp.CommitSortStrategies.Time + }; + var expected = libgit2.Commits.QueryBy(filter).Select(commit => commit.Sha).ToList(); + + actual.ShouldBe(expected); + } + + [Test] + public void FindsTheMergeBaseOfDivergedBranches() + { + using var repository = CreateMergedRepository(); + + AssertMergeBaseParity(repository, repository.RevParse("main"), repository.RevParse("feature")); + } + + [Test] + public void FindsTheMergeBaseOfCrissCrossBranches() + { + using var repository = CreateCrissCrossRepository(); + + AssertMergeBaseParity(repository, repository.RevParse("main"), repository.RevParse("dev")); + AssertMergeBaseParity(repository, repository.RevParse("dev"), repository.RevParse("main")); + } + + [Test] + public void TheMergeBaseOfACommitAndItsAncestorIsTheAncestor() + { + using var repository = CreateLinearRepository(); + var ancestor = repository.RevParse("HEAD~3"); + var tip = repository.RevParse("HEAD"); + + using var store = repository.OpenObjectStore(); + var walker = new GitRevisionWalker(store); + + walker.FindMergeBase(GitObjectId.Parse(tip), GitObjectId.Parse(ancestor)).ShouldBe(GitObjectId.Parse(ancestor)); + walker.FindMergeBase(GitObjectId.Parse(ancestor), GitObjectId.Parse(tip)).ShouldBe(GitObjectId.Parse(ancestor)); + walker.FindMergeBase(GitObjectId.Parse(tip), GitObjectId.Parse(tip)).ShouldBe(GitObjectId.Parse(tip)); + } + + [Test] + public void UnrelatedHistoriesHaveNoMergeBase() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "a\n"); + var main = repository.Commit("on main"); + repository.Run("checkout", "-q", "--orphan", "detached-root"); + repository.WriteFile("b.txt", "b\n"); + var orphan = repository.Commit("orphan root"); + + using var store = repository.OpenObjectStore(); + new GitRevisionWalker(store) + .FindMergeBase(GitObjectId.Parse(main), GitObjectId.Parse(orphan)) + .ShouldBeNull(); + } + + [Test] + public void WalksUpToTheShallowBoundary() + { + using var repository = CreateLinearRepository(); + using var clone = new TempDirectory(); + repository.Run("clone", "-q", "--depth", "2", "file://" + repository.RepositoryPath, clone.FullPath); + + var layout = GitRepositoryLayout.Discover(clone.FullPath); + using var store = layout.CreateObjectStore(); + + var options = new GitRevisionWalkOptions(); + options.Include.Add(layout.CreateReferenceStore().ResolveToObjectId("HEAD")!.Value); + + // The commits listed in .git/shallow are grafted parentless, so the walk stops + // exactly at the boundary instead of chasing the missing parents. + new GitRevisionWalker(store, layout.ReadShallowCommits().ToHashSet()).Walk(options).Count.ShouldBe(2); + } + + [Test] + public void TheShallowBoundaryWinsOverPresentParentObjects() + { + // A .git/shallow file naming a mid-history commit of a full repository: the parent + // objects exist, but git and libgit2 honor the graft and stop at the boundary anyway. + using var repository = CreateLinearRepository(); + var boundary = repository.RevParse("HEAD~2"); + File.WriteAllText(Path.Combine(repository.GitDirectory, "shallow"), boundary + "\n"); + + var layout = GitRepositoryLayout.Discover(repository.RepositoryPath); + using var store = layout.CreateObjectStore(); + + var options = new GitRevisionWalkOptions(); + options.Include.Add(repository.ResolveId("HEAD")); + + var walked = new GitRevisionWalker(store, layout.ReadShallowCommits().ToHashSet()).Walk(options); + + walked.Count.ShouldBe(3); + walked[^1].Sha.ShouldBe(GitObjectId.Parse(boundary)); + walked[^1].Parents.ShouldBeEmpty(); + } + + [Test] + public void AMissingParentObjectFailsTheWalkInsteadOfTruncatingHistory() + { + using var repository = CreateLinearRepository(); + var missingParent = repository.RevParse("HEAD~2"); + DeleteLooseObject(repository, missingParent); + + using var store = repository.OpenObjectStore(); + var options = new GitRevisionWalkOptions(); + options.Include.Add(repository.ResolveId("HEAD")); + + // Without a shallow boundary explaining the gap, the repository is corrupt: libgit2 + // fails the walk on a missing parent, and truncating silently would yield a wrong version. + var exception = Should.Throw(() => new GitRevisionWalker(store).Walk(options)); + + exception.ObjectNotFound.ShouldBeTrue(); + exception.Message.ShouldContain(missingParent); + } + + [Test] + public void WalkingFromAMissingCommitFails() + { + using var repository = CreateLinearRepository(); + using var store = repository.OpenObjectStore(); + + var options = new GitRevisionWalkOptions(); + options.Include.Add(GitObjectId.Parse("0123456789012345678901234567890123456789")); + + // libgit2's revwalk push fails on a missing commit; emitting a walk + // containing a null commit instead would blow up far from the cause. + Should.Throw(() => new GitRevisionWalker(store).Walk(options)); + } + + private static void AssertWalkParity(GitTestRepository repository, GitRevisionWalkOptions options, bool head, string? excludeSha = null) + { + if (head) + { + options.Include.Add(repository.ResolveId("HEAD")); + } + + using var store = repository.OpenObjectStore(); + var actual = new GitRevisionWalker(store).Walk(options).Select(commit => commit.Sha.ToString()).ToList(); + + using var libgit2 = new Repository(repository.RepositoryPath); + var filter = new global::LibGit2Sharp.CommitFilter + { + IncludeReachableFrom = options.Include[0].ToString(), + SortBy = ToLibGit2Sort(options.Sort), + FirstParentOnly = options.FirstParentOnly + }; + + if (excludeSha is not null) + { + filter.ExcludeReachableFrom = excludeSha; + } + + var expected = libgit2.Commits.QueryBy(filter).Select(commit => commit.Sha).ToList(); + + actual.ShouldBe(expected, $"sort: {options.Sort}, firstParent: {options.FirstParentOnly}, exclude: {excludeSha}"); + actual.ShouldNotBeEmpty(); + } + + private static void AssertMergeBaseParity(GitTestRepository repository, string first, string second) + { + using var store = repository.OpenObjectStore(); + var actual = new GitRevisionWalker(store).FindMergeBase(GitObjectId.Parse(first), GitObjectId.Parse(second)); + + using var libgit2 = new Repository(repository.RepositoryPath); + var expected = libgit2.ObjectDatabase.FindMergeBase(libgit2.Lookup(first), libgit2.Lookup(second)); + + expected.ShouldNotBeNull(); + actual.ShouldNotBeNull(); + actual.Value.ToString().ShouldBe(expected.Sha); + } + + private static global::LibGit2Sharp.CommitSortStrategies ToLibGit2Sort(GitRevisionSortStrategies sort) + { + var result = global::LibGit2Sharp.CommitSortStrategies.None; + + if (sort.HasFlag(GitRevisionSortStrategies.Topological)) + { + result |= global::LibGit2Sharp.CommitSortStrategies.Topological; + } + + if (sort.HasFlag(GitRevisionSortStrategies.Time)) + { + result |= global::LibGit2Sharp.CommitSortStrategies.Time; + } + + if (sort.HasFlag(GitRevisionSortStrategies.Reverse)) + { + result |= global::LibGit2Sharp.CommitSortStrategies.Reverse; + } + + return result; + } + + private static void DeleteLooseObject(GitTestRepository repository, string sha) + { + // Git marks loose object files read-only; reset the attribute so the delete + // succeeds on Windows. + var path = Path.Combine(repository.ObjectsDirectory, sha[..2], sha[2..]); + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + + private static GitTestRepository CreateLinearRepository() + { + var repository = new GitTestRepository(); + + for (var i = 0; i < 6; i++) + { + repository.WriteFile("file.txt", $"content {i}\n"); + repository.Commit($"commit {i}"); + + if (i == 0) + { + repository.Run("tag", "v0"); + } + } + + return repository; + } + + private static GitTestRepository CreateMergedRepository() + { + var repository = new GitTestRepository(); + repository.WriteFile("main.txt", "0\n"); + repository.Commit("main 0"); + repository.Run("tag", "v0"); + + repository.Run("checkout", "-q", "-b", "feature"); + repository.WriteFile("feature.txt", "1\n"); + repository.Commit("feature 1"); + repository.WriteFile("feature.txt", "2\n"); + repository.Commit("feature 2"); + + repository.Run("checkout", "-q", "main"); + repository.WriteFile("main.txt", "1\n"); + repository.Commit("main 1"); + repository.WriteFile("main.txt", "2\n"); + repository.Commit("main 2"); + + repository.Merge("feature"); + return repository; + } + + private static GitTestRepository CreateEqualTimestampRepository() + { + var repository = new GitTestRepository(); + repository.WriteFile("main.txt", "0\n"); + repository.Commit("main 0"); + + repository.Run("checkout", "-q", "-b", "feature"); + repository.WriteFile("feature.txt", "1\n"); + repository.Commit("feature 1", advanceClock: false); + repository.WriteFile("feature.txt", "2\n"); + repository.Commit("feature 2", advanceClock: false); + + repository.Run("checkout", "-q", "main"); + repository.WriteFile("main.txt", "1\n"); + repository.Commit("main 1", advanceClock: false); + repository.WriteFile("main.txt", "2\n"); + repository.Commit("main 2", advanceClock: false); + + repository.Merge("feature", advanceClock: false); + return repository; + } + + private static GitTestRepository CreateCrissCrossRepository() + { + var repository = new GitTestRepository(); + repository.WriteFile("main.txt", "0\n"); + repository.Commit("root"); + + repository.Run("checkout", "-q", "-b", "dev"); + repository.WriteFile("dev.txt", "1\n"); + var devCommit = repository.Commit("dev 1"); + + repository.Run("checkout", "-q", "main"); + repository.WriteFile("main.txt", "1\n"); + var mainCommit = repository.Commit("main 1"); + + // Merge in both directions to create a criss-cross: two best common ancestors. + repository.Merge(devCommit); + repository.Run("checkout", "-q", "dev"); + repository.Merge(mainCommit); + + return repository; + } + + private static GitTestRepository CreateOctopusRepository() + { + var repository = new GitTestRepository(); + repository.WriteFile("main.txt", "0\n"); + repository.Commit("root"); + + foreach (var branch in new[] { "b1", "b2" }) + { + repository.Run("checkout", "-q", "-b", branch, "main"); + repository.WriteFile($"{branch}.txt", "1\n"); + repository.Commit($"{branch} 1"); + } + + repository.Run("checkout", "-q", "main"); + repository.WriteFile("main.txt", "1\n"); + repository.Commit("main 1"); + repository.Merge("b1", "b2"); + + return repository; + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitStatusCalculatorTests.cs b/src/GitVersion.Git.Managed.Tests/GitStatusCalculatorTests.cs new file mode 100644 index 0000000000..bf1141d34d --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitStatusCalculatorTests.cs @@ -0,0 +1,241 @@ +using LibGit2Sharp; + +namespace GitVersion.Git.Managed.Tests; + +/// +/// Validates against the exact expression the +/// LibGit2Sharp adapter uses for IGitRepository.UncommittedChangesCount. +/// +[TestFixture] +public class GitStatusCalculatorTests +{ + [Test] + public void ACleanWorkingDirectoryHasNoChanges() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.WriteFile("sub/b.txt", "two\n"); + repository.Commit("commit"); + + AssertParity(repository, expectedCount: 0); + } + + [Test] + public void CountsModifiedStagedDeletedAndUntrackedPathsOnce() + { + using var repository = new GitTestRepository(); + repository.WriteFile("modified.txt", "one\n"); + repository.WriteFile("both.txt", "one\n"); + repository.WriteFile("staged.txt", "one\n"); + repository.WriteFile("deleted.txt", "one\n"); + repository.WriteFile("removed.txt", "one\n"); + repository.Commit("commit"); + + repository.WriteFile("modified.txt", "two\n"); // modified, unstaged + repository.WriteFile("staged.txt", "two\n"); + repository.Run("add", "staged.txt"); // modified, staged + repository.WriteFile("both.txt", "two\n"); + repository.Run("add", "both.txt"); + repository.WriteFile("both.txt", "three\n"); // staged and modified again + File.Delete(Path.Combine(repository.RepositoryPath, "deleted.txt")); // deleted, unstaged + repository.Run("rm", "-q", "removed.txt"); // deleted, staged + repository.WriteFile("untracked.txt", "new\n"); // untracked + repository.WriteFile("sub/untracked.txt", "new\n"); // untracked in new directory + + AssertParity(repository, expectedCount: 7); + } + + [Test] + public void ContentRestoredToTheCommittedStateIsClean() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.Commit("commit"); + + // Touch the file (mtime changes, content identical): requires re-hashing to prove clean. + repository.WriteFile("a.txt", "one\n"); + + AssertParity(repository, expectedCount: 0); + } + + [Test] + public void RespectsGitIgnoreRules() + { + using var repository = new GitTestRepository(); + repository.WriteFile(".gitignore", "*.log\nbuild/\n!important.log\n/anchored.txt\n"); + repository.WriteFile("sub/.gitignore", "local-*\n"); + repository.WriteFile("a.txt", "one\n"); + repository.Commit("commit"); + + repository.WriteFile("noise.log", "ignored\n"); // ignored by *.log + repository.WriteFile("important.log", "kept\n"); // re-included by negation + repository.WriteFile("build/out.txt", "ignored\n"); // ignored directory + repository.WriteFile("anchored.txt", "ignored\n"); // anchored to root + repository.WriteFile("sub/anchored.txt", "not anchored\n");// same name below root is not ignored + repository.WriteFile("sub/local-cache", "ignored\n"); // nested .gitignore + repository.WriteFile("sub/tracked-new.txt", "new\n"); // untracked + + AssertParity(repository, expectedCount: 3); // important.log, sub/anchored.txt, sub/tracked-new.txt + } + + [Test] + public void TreatsNonBoundaryConsecutiveAsterisksAsSlashExcluding() + { + using var repository = new GitTestRepository(); + repository.WriteFile(".gitignore", "out**txt\n"); + repository.Commit("commit"); + + repository.WriteFile("output.txt", "ignored\n"); // matched by out**txt + repository.WriteFile("out/nested/file.txt", "kept\n"); // ** without a boundary must not cross '/' + + AssertParity(repository, expectedCount: 1); + } + + [Test] + public void RespectsInfoExclude() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.Commit("commit"); + + Directory.CreateDirectory(Path.Combine(repository.GitDirectory, "info")); + File.WriteAllText(Path.Combine(repository.GitDirectory, "info", "exclude"), "*.tmp\n"); + + repository.WriteFile("scratch.tmp", "ignored\n"); + repository.WriteFile("kept.txt", "untracked\n"); + + AssertParity(repository, expectedCount: 1); + } + + [Test] + public void HonorsCoreIgnoreCaseForIgnorePatterns() + { + using var repository = new GitTestRepository(); + repository.WriteFile(".gitignore", "BIN/\n*.LOG\n"); + repository.Commit("commit"); + repository.Run("config", "core.ignorecase", "true"); + + repository.WriteFile("bin/out.txt", "ignored\n"); // ignored by BIN/ under ignorecase + repository.WriteFile("noise.log", "ignored\n"); // ignored by *.LOG under ignorecase + repository.WriteFile("kept.txt", "untracked\n"); + + AssertParity(repository, expectedCount: 1); + } + + [Test] + public void MatchesIgnorePatternsCaseSensitivelyWhenIgnoreCaseIsOff() + { + using var repository = new GitTestRepository(); + repository.WriteFile(".gitignore", "BIN/\n*.LOG\n"); + repository.Commit("commit"); + repository.Run("config", "core.ignorecase", "false"); + + repository.WriteFile("bin/out.txt", "kept\n"); + repository.WriteFile("noise.log", "kept\n"); + repository.WriteFile("kept.txt", "untracked\n"); + + AssertParity(repository, expectedCount: 3); + } + + [Test] + public void CountsExecutableBitChanges() + { + if (OperatingSystem.IsWindows()) + { + Assert.Ignore("The executable bit does not exist on Windows."); + return; + } + + using var repository = new GitTestRepository(); + repository.WriteFile("script.sh", "#!/bin/sh\n"); + repository.Commit("commit"); + + var scriptPath = Path.Combine(repository.RepositoryPath, "script.sh"); + File.SetUnixFileMode(scriptPath, File.GetUnixFileMode(scriptPath) | UnixFileMode.UserExecute); + + AssertParity(repository, expectedCount: 1); + } + + [Test] + public void CountsAnUntrackedDirectorySymbolicLinkAsASingleEntry() + { + if (OperatingSystem.IsWindows()) + { + Assert.Ignore("Creating symbolic links requires elevated privileges on Windows."); + return; + } + + using var repository = new GitTestRepository(); + repository.WriteFile("target/one.txt", "one\n"); + repository.WriteFile("target/two.txt", "two\n"); + repository.Commit("commit"); + + // A symbolic link to a directory is a single untracked entry (its blob is the raw + // link target), and a self-referencing link must not cause unbounded recursion. + Directory.CreateSymbolicLink(Path.Combine(repository.RepositoryPath, "link"), "target"); + Directory.CreateSymbolicLink(Path.Combine(repository.RepositoryPath, "self"), "."); + + AssertParity(repository, expectedCount: 2); + } + + [Test] + public void CountsUntrackedFilesInAnEmptyRepository() + { + using var repository = new GitTestRepository(); + repository.WriteFile("untracked.txt", "new\n"); + repository.WriteFile("also-untracked.txt", "new\n"); + + AssertParity(repository, expectedCount: 2); + } + + [Test] + public void WorksAgainstAnIndexInVersion4Format() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.WriteFile("sub/b.txt", "two\n"); + repository.Commit("commit"); + repository.Run("update-index", "--index-version=4"); + + repository.WriteFile("a.txt", "two\n"); + repository.WriteFile("untracked.txt", "new\n"); + + AssertParity(repository, expectedCount: 2); + } + + private static void AssertParity(GitTestRepository repository, int expectedCount) + { + var actual = ManagedCount(repository); + var expected = LibGit2Count(repository); + + actual.ShouldBe(expected, "managed count should match the libgit2 adapter"); + actual.ShouldBe(expectedCount, "scenario expectation"); + } + + private static int ManagedCount(GitTestRepository repository) + { + var layout = GitRepositoryLayout.Discover(repository.RepositoryPath); + using var store = layout.CreateObjectStore(); + var calculator = new GitStatusCalculator(layout, store); + + return layout.CreateReferenceStore().ResolveToObjectId("HEAD") is { } headId + ? calculator.CountUncommittedChanges(store.GetCommit(headId).Tree) + : calculator.CountChangesInEmptyRepository(); + } + + private static int LibGit2Count(GitTestRepository repository) + { + // The exact expression from GitVersion.LibGit2Sharp's GetUncommittedChangesCountInternal. + using var libgit2 = new Repository(repository.RepositoryPath); + + if (libgit2.Head?.Tip == null) + { + var status = libgit2.RetrieveStatus(); + return status.Untracked.Count() + status.Staged.Count(); + } + + return libgit2.Diff + .Compare(libgit2.Head.Tip.Tree, DiffTargets.Index | DiffTargets.WorkingDirectory) + .Count; + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitTestRepository.cs b/src/GitVersion.Git.Managed.Tests/GitTestRepository.cs new file mode 100644 index 0000000000..fafc264f4e --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitTestRepository.cs @@ -0,0 +1,194 @@ +using GitVersion.Helpers; + +namespace GitVersion.Git.Managed.Tests; + +/// +/// Creates a real Git repository in a temporary directory by invoking the git +/// command-line executable, with deterministic author/committer identities and dates. +/// +internal sealed class GitTestRepository : IDisposable +{ + public const string AuthorName = "Test Author"; + public const string AuthorEmail = "author@example.com"; + public const string CommitterName = "Test Committer"; + public const string CommitterEmail = "committer@example.com"; + + private static readonly DateTimeOffset BaseDate = new(2023, 6, 1, 10, 0, 0, TimeSpan.FromHours(2)); + + private int ticks; + + public GitTestRepository() + { + RepositoryPath = Path.Combine(Path.GetTempPath(), "managed-git-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(RepositoryPath); + Run("init", "-q", "-b", "main"); + + // Use the path exactly as git sees it (symlinks resolved, e.g. /var -> /private/var on + // macOS), so paths git writes into gitdir/commondir files compare equal to ours. + RepositoryPath = Path.GetFullPath(Run("rev-parse", "--show-toplevel")); + } + + public string RepositoryPath { get; } + + public string GitDirectory => Path.Combine(RepositoryPath, ".git"); + + public string ObjectsDirectory => Path.Combine(GitDirectory, "objects"); + + /// + /// Gets the date used for the author and the committer of the most recent commit or tag. + /// + public DateTimeOffset CurrentDate => BaseDate.AddMinutes(this.ticks); + + /// + /// Runs git with the given arguments in the repository and returns its standard output. + /// + public string Run(params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = "git", + WorkingDirectory = RepositoryPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8 + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + var date = $"{CurrentDate.ToUnixTimeSeconds()} +0200"; + startInfo.Environment["GIT_AUTHOR_NAME"] = AuthorName; + startInfo.Environment["GIT_AUTHOR_EMAIL"] = AuthorEmail; + startInfo.Environment["GIT_AUTHOR_DATE"] = date; + startInfo.Environment["GIT_COMMITTER_NAME"] = CommitterName; + startInfo.Environment["GIT_COMMITTER_EMAIL"] = CommitterEmail; + startInfo.Environment["GIT_COMMITTER_DATE"] = date; + startInfo.Environment["GIT_CONFIG_GLOBAL"] = Path.Combine(RepositoryPath, "no-global-config"); + startInfo.Environment["GIT_CONFIG_NOSYSTEM"] = "1"; + startInfo.Environment["GIT_TERMINAL_PROMPT"] = "0"; + + using var process = new Process(); + process.StartInfo = startInfo; + process.Start(); + + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"'git {string.Join(' ', arguments)}' failed with exit code {process.ExitCode}: {standardError}"); + } + + return standardOutput.TrimEnd('\n'); + } + + /// + /// Writes a file (relative to the repository root), creating parent directories as needed. + /// + public void WriteFile(string relativePath, string content) + { + var fullPath = Path.Combine(RepositoryPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } + + /// + /// Stages all changes and creates a commit with a deterministic date. Returns the commit sha. + /// + /// The commit message. + /// + /// Whether to advance the deterministic clock before committing. Pass + /// to create commits sharing the same committer timestamp. + /// + public string Commit(string message, bool advanceClock = true) + { + if (advanceClock) + { + this.ticks++; + } + + Run("add", "--all"); + Run("commit", "-q", "--no-verify", "--allow-empty", "-m", message); + return RevParse("HEAD"); + } + + /// + /// Creates a commit whose message is taken verbatim from the given bytes. + /// + public string CommitWithMessageBytes(byte[] messageBytes, string? commitEncoding = null) + { + this.ticks++; + var messageFile = Path.Combine(RepositoryPath, "..", $"message-{Guid.NewGuid():N}.txt"); + File.WriteAllBytes(messageFile, messageBytes); + + try + { + Run("add", "--all"); + var arguments = new List(); + if (commitEncoding is not null) + { + arguments.AddRange(["-c", $"i18n.commitEncoding={commitEncoding}"]); + } + + arguments.AddRange(["commit", "-q", "--no-verify", "--allow-empty", "-F", messageFile]); + Run([.. arguments]); + return RevParse("HEAD"); + } + finally + { + File.Delete(messageFile); + } + } + + /// + /// Merges the given commits or branches into the current branch with a deterministic date. + /// + public string Merge(string firstBranch, string? secondBranch = null, bool advanceClock = true) + { + if (advanceClock) + { + this.ticks++; + } + + List arguments = ["merge", "-q", "--no-ff", "--no-edit", firstBranch]; + + if (secondBranch is not null) + { + arguments.Add(secondBranch); + } + + Run([.. arguments]); + return RevParse("HEAD"); + } + + public string RevParse(string reference) => Run("rev-parse", reference); + + public GitObjectId ResolveId(string reference) => GitObjectId.Parse(RevParse(reference)); + + public GitObjectStore OpenObjectStore() => new(ObjectsDirectory); + + public GitReferenceStore OpenReferenceStore() => new(GitDirectory); + + public void Dispose() + { + try + { + // Git marks pack and loose-object files read-only; a bare recursive delete + // fails on them on Windows. The helper resets attributes before deleting. + FileSystemHelper.Directory.DeleteDirectory(RepositoryPath); + } + catch (IOException) + { + // Best effort cleanup of the temporary directory. + } + catch (UnauthorizedAccessException) + { + // Best effort cleanup of the temporary directory. + } + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitTreeDiffTests.cs b/src/GitVersion.Git.Managed.Tests/GitTreeDiffTests.cs new file mode 100644 index 0000000000..d7203da09f --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitTreeDiffTests.cs @@ -0,0 +1,158 @@ +using LibGit2Sharp; + +namespace GitVersion.Git.Managed.Tests; + +/// +/// Validates against libgit2's default TreeChanges path +/// list, which is what GitVersion exposes as ICommit.DiffPaths. +/// +[TestFixture] +public class GitTreeDiffTests +{ + [Test] + public void ChangedPathsMatchLibGit2ForEveryCommit() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.WriteFile("sub/b.txt", "one\n"); + repository.WriteFile("sub/deep/c.txt", "one\n"); + repository.Commit("root commit"); + + repository.WriteFile("a.txt", "two\n"); + repository.WriteFile("sub/new.txt", "new\n"); + repository.Commit("modify and add"); + + File.Delete(Path.Combine(repository.RepositoryPath, "sub", "b.txt")); + repository.Commit("delete nested file"); + + repository.WriteFile("d.txt", "d\n"); + repository.Run("update-index", "--chmod=+x", "a.txt"); + repository.Commit("exec bit and new file"); + + AssertDiffPathsParityForAllCommits(repository); + } + + [Test] + public void ChangedPathsMatchLibGit2WhenAFileBecomesADirectory() + { + using var repository = new GitTestRepository(); + repository.WriteFile("thing", "file\n"); + repository.WriteFile("z.txt", "z\n"); + repository.Commit("file"); + + File.Delete(Path.Combine(repository.RepositoryPath, "thing")); + repository.WriteFile("thing/inner.txt", "dir\n"); + repository.WriteFile("thing/other.txt", "dir\n"); + repository.Commit("becomes a directory"); + + File.Delete(Path.Combine(repository.RepositoryPath, "thing", "inner.txt")); + File.Delete(Path.Combine(repository.RepositoryPath, "thing", "other.txt")); + Directory.Delete(Path.Combine(repository.RepositoryPath, "thing")); + repository.WriteFile("thing", "file again\n"); + repository.Commit("becomes a file again"); + + AssertDiffPathsParityForAllCommits(repository); + } + + [Test] + public void ChangedPathsOfMergeCommitsUseTheFirstParent() + { + using var repository = new GitTestRepository(); + repository.WriteFile("main.txt", "0\n"); + repository.Commit("main 0"); + repository.Run("checkout", "-q", "-b", "feature"); + repository.WriteFile("feature.txt", "1\n"); + repository.Commit("feature 1"); + repository.Run("checkout", "-q", "main"); + repository.WriteFile("main.txt", "1\n"); + repository.Commit("main 1"); + repository.Merge("feature"); + + AssertDiffPathsParityForAllCommits(repository); + } + + [Test] + public void ChangedPathsAlignEntriesByRawByteOrderNotUtf16Order() + { + // "\uE000" (EE 80 80 in UTF-8) sorts after "\U0001F600" (F0 9F 98 80) in UTF-16 + // code units (0xD83D < 0xE000) but before it in git's raw unsigned byte order + // (0xEE < 0xF0). Comparing the decoded strings misaligns the two-pointer merge + // and reports the unchanged emoji file as changed. + using var repository = new GitTestRepository(); + repository.WriteFile("\uE000.txt", "private use\n"); + repository.WriteFile("\U0001F600.txt", "emoji\n"); + var first = repository.ResolveId(repository.Commit("both files")); + + File.Delete(Path.Combine(repository.RepositoryPath, "\uE000.txt")); + var second = repository.ResolveId(repository.Commit("delete the private-use file")); + + using var store = repository.OpenObjectStore(); + var treeDiff = new GitTreeDiff(store); + + treeDiff.GetChangedPaths(store.GetCommit(first).Tree, store.GetCommit(second).Tree) + .ShouldBe(["\uE000.txt"]); + + AssertDiffPathsParityForAllCommits(repository); + } + + [Test] + public void IdenticalTreesProduceNoChangedPaths() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.Commit("commit"); + + using var store = repository.OpenObjectStore(); + var tree = store.GetCommit(repository.ResolveId("HEAD")).Tree; + + new GitTreeDiff(store).GetChangedPaths(tree, tree).ShouldBeEmpty(); + } + + [Test] + public void FlattenTreeListsAllBlobPaths() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "one\n"); + repository.WriteFile("sub/b.txt", "one\n"); + repository.WriteFile("sub/deep/c.txt", "one\n"); + repository.Commit("commit"); + + using var store = repository.OpenObjectStore(); + var tree = store.GetCommit(repository.ResolveId("HEAD")).Tree; + + var files = new GitTreeDiff(store).FlattenTree(tree); + + files.Keys.Order(StringComparer.Ordinal).ShouldBe(["a.txt", "sub/b.txt", "sub/deep/c.txt"]); + files["a.txt"].Sha.ToString().ShouldBe(repository.RevParse("HEAD:a.txt")); + } + + private static void AssertDiffPathsParityForAllCommits(GitTestRepository repository) + { + using var store = repository.OpenObjectStore(); + var treeDiff = new GitTreeDiff(store); + var walker = new GitRevisionWalker(store); + + var options = new GitRevisionWalkOptions(); + options.Include.Add(repository.ResolveId("HEAD")); + + using var libgit2 = new Repository(repository.RepositoryPath); + + foreach (var commit in walker.Walk(options)) + { + var parentTree = commit.Parents.Count > 0 + ? store.GetCommit(commit.Parents[0]).Tree + : (GitObjectId?)null; + + var actual = treeDiff.GetChangedPaths(parentTree, commit.Tree); + + // The adapter's DiffPaths: Compare(commit.Tree, firstParent?.Tree).Paths + var libgit2Commit = libgit2.Lookup(commit.Sha.ToString())!; + var expected = libgit2.Diff + .Compare(libgit2Commit.Tree, libgit2Commit.Parents.FirstOrDefault()?.Tree) + .Select(element => element.Path) + .ToList(); + + actual.ShouldBe(expected, $"commit: {commit.Message.TrimEnd()}"); + } + } +} diff --git a/src/GitVersion.Git.Managed.Tests/GitVersion.Git.Managed.Tests.csproj b/src/GitVersion.Git.Managed.Tests/GitVersion.Git.Managed.Tests.csproj new file mode 100644 index 0000000000..fd5fab9235 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/GitVersion.Git.Managed.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/GitVersion.Git.Managed.Tests/LooseObjectTests.cs b/src/GitVersion.Git.Managed.Tests/LooseObjectTests.cs new file mode 100644 index 0000000000..e63893b422 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/LooseObjectTests.cs @@ -0,0 +1,190 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class LooseObjectTests +{ + [Test] + public void ReadsACommitFromLooseObjects() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "hello\n"); + var firstSha = repository.Commit("first commit"); + repository.WriteFile("file.txt", "hello world\n"); + var secondSha = repository.Commit("second commit"); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(secondSha)); + + commit.Sha.ToString().ShouldBe(secondSha); + commit.Tree.ToString().ShouldBe(repository.RevParse("HEAD^{tree}")); + commit.Parents.Count.ShouldBe(1); + commit.Parents[0].ToString().ShouldBe(firstSha); + commit.Message.ShouldBe("second commit\n"); + + commit.Author.Name.ShouldBe(GitTestRepository.AuthorName); + commit.Author.Email.ShouldBe(GitTestRepository.AuthorEmail); + commit.Committer.Name.ShouldBe(GitTestRepository.CommitterName); + commit.Committer.Email.ShouldBe(GitTestRepository.CommitterEmail); + } + + [Test] + public void ReadsARootCommitWithoutParents() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "hello\n"); + var sha = repository.Commit("initial"); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(sha)); + + commit.Parents.ShouldBeEmpty(); + commit.Message.ShouldBe("initial\n"); + } + + [Test] + public void CommitMatchesGitLogOutput() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("subject line\n\nbody line one\nbody line two"); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(sha)); + + var expected = repository + .Run("log", "-1", "--format=%an%n%ae%n%at%n%cn%n%ce%n%ct", sha) + .Split('\n'); + + commit.Author.Name.ShouldBe(expected[0]); + commit.Author.Email.ShouldBe(expected[1]); + commit.Author.When.ToUnixTimeSeconds().ShouldBe(long.Parse(expected[2])); + commit.Committer.Name.ShouldBe(expected[3]); + commit.Committer.Email.ShouldBe(expected[4]); + commit.Committer.When.ToUnixTimeSeconds().ShouldBe(long.Parse(expected[5])); + + commit.Author.When.Offset.ShouldBe(TimeSpan.FromHours(2)); + commit.Author.When.ShouldBe(repository.CurrentDate); + commit.Message.ShouldBe("subject line\n\nbody line one\nbody line two\n"); + } + + [Test] + public void CommitMessageMatchesGitCatFileOutput() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a message with special characters: äöü — ✓"); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(sha)); + + var raw = repository.Run("cat-file", "commit", sha); + var expectedMessage = raw[(raw.IndexOf("\n\n", StringComparison.Ordinal) + 2)..]; + + commit.Message.TrimEnd('\n').ShouldBe(expectedMessage); + } + + [Test] + public void ReadsACommitMessageHonoringTheEncodingHeader() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var messageBytes = Encoding.Latin1.GetBytes("café à la crème\n"); + var sha = repository.CommitWithMessageBytes(messageBytes, "ISO-8859-1"); + + repository.Run("cat-file", "commit", sha).ShouldContain("encoding ISO-8859-1"); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(sha)); + + commit.Message.ShouldBe("café à la crème\n"); + } + + [Test] + public void FallsBackToLatin1ForInvalidUtf8WithoutAnEncodingHeader() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var messageBytes = Encoding.Latin1.GetBytes("café\n"); + var sha = repository.CommitWithMessageBytes(messageBytes); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(sha)); + + commit.Message.ShouldBe("café\n"); + } + + [Test] + public void ReadsABlobFromLooseObjects() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "blob content\n"); + repository.Commit("add file"); + + using var store = repository.OpenObjectStore(); + using var blob = store.GetBlob(repository.ResolveId("HEAD:file.txt")); + using var reader = new StreamReader(blob); + + reader.ReadToEnd().ShouldBe("blob content\n"); + } + + [Test] + public void ReportsTheObjectTypeWhenItIsNotKnownUpFront() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + using var store = repository.OpenObjectStore(); + store.TryGetObject(GitObjectId.Parse(sha), out var stream, out var objectType).ShouldBeTrue(); + + using (stream) + { + objectType.ShouldBe("commit"); + } + } + + [Test] + public void DoesNotFindAMissingObject() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("a commit"); + + var missing = GitObjectId.Parse("0123456789abcdef0123456789abcdef01234567"); + + using var store = repository.OpenObjectStore(); + store.TryGetObject(missing, "commit", out _).ShouldBeFalse(); + + var exception = Should.Throw(() => store.GetCommit(missing)); + exception.ObjectNotFound.ShouldBeTrue(); + } + + [Test] + public void DoesNotFindAnObjectWhenRequestedWithTheWrongType() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var sha = repository.Commit("a commit"); + + using var store = repository.OpenObjectStore(); + store.TryGetObject(GitObjectId.Parse(sha), "blob", out _).ShouldBeFalse(); + } + + [Test] + public void ReadsObjectsFromAnAlternateObjectDatabase() + { + using var upstream = new GitTestRepository(); + upstream.WriteFile("file.txt", "shared content\n"); + var sha = upstream.Commit("shared commit"); + + using var dependent = new GitTestRepository(); + var infoDirectory = Path.Combine(dependent.ObjectsDirectory, "info"); + Directory.CreateDirectory(infoDirectory); + File.WriteAllText(Path.Combine(infoDirectory, "alternates"), upstream.ObjectsDirectory + "\n"); + + using var store = dependent.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(sha)); + + commit.Message.ShouldBe("shared commit\n"); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/MultiPackIndexTests.cs b/src/GitVersion.Git.Managed.Tests/MultiPackIndexTests.cs new file mode 100644 index 0000000000..5febc128d0 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/MultiPackIndexTests.cs @@ -0,0 +1,82 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class MultiPackIndexTests +{ + [Test] + public void ReadsObjectsFromMultiplePacksThroughTheMultiPackIndex() + { + using var repository = new GitTestRepository(); + + repository.WriteFile("file.txt", "first\n"); + var firstSha = repository.Commit("first commit"); + repository.Run("repack", "-a", "-d", "-q"); + + repository.WriteFile("file.txt", "second\n"); + var secondSha = repository.Commit("second commit"); + repository.Run("repack", "-d", "-q"); + + repository.Run("multi-pack-index", "write"); + + var packDirectory = Path.Combine(repository.ObjectsDirectory, "pack"); + File.Exists(Path.Combine(packDirectory, "multi-pack-index")).ShouldBeTrue(); + Directory.GetFiles(packDirectory, "*.pack").Length.ShouldBe(2); + + using var store = repository.OpenObjectStore(); + store.GetCommit(GitObjectId.Parse(firstSha)).Message.ShouldBe("first commit\n"); + + var second = store.GetCommit(GitObjectId.Parse(secondSha)); + second.Message.ShouldBe("second commit\n"); + second.Parents[0].ToString().ShouldBe(firstSha); + } + + [Test] + public void TheMultiPackIndexReaderLooksUpObjectsAcrossPacks() + { + using var repository = new GitTestRepository(); + + repository.WriteFile("file.txt", "first\n"); + var firstSha = repository.Commit("first commit"); + repository.Run("repack", "-a", "-d", "-q"); + + repository.WriteFile("file.txt", "second\n"); + var secondSha = repository.Commit("second commit"); + repository.Run("repack", "-d", "-q"); + + repository.Run("multi-pack-index", "write"); + + using var stream = File.OpenRead(Path.Combine(repository.ObjectsDirectory, "pack", "multi-pack-index")); + using var reader = new GitMultiPackIndexReader(stream); + + reader.PackNames.Count.ShouldBe(2); + reader.PackNames.ShouldAllBe(name => name.StartsWith("pack-")); + + var first = reader.GetOffset(GitObjectId.Parse(firstSha)); + var second = reader.GetOffset(GitObjectId.Parse(secondSha)); + + first.ShouldNotBeNull(); + second.ShouldNotBeNull(); + first.Value.PackIndex.ShouldNotBe(second.Value.PackIndex, "the two commits live in different packs"); + first.Value.Offset.ShouldBeGreaterThan(0); + second.Value.Offset.ShouldBeGreaterThan(0); + + reader.GetOffset(GitObjectId.Parse("0123456789abcdef0123456789abcdef01234567")).ShouldBeNull(); + } + + [Test] + public void ReadsLooseObjectsWhenAMultiPackIndexIsPresent() + { + using var repository = new GitTestRepository(); + + repository.WriteFile("file.txt", "first\n"); + repository.Commit("first commit"); + repository.Run("repack", "-a", "-d", "-q"); + repository.Run("multi-pack-index", "write"); + + repository.WriteFile("file.txt", "second\n"); + var looseSha = repository.Commit("loose commit"); + + using var store = repository.OpenObjectStore(); + store.GetCommit(GitObjectId.Parse(looseSha)).Message.ShouldBe("loose commit\n"); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/PackedObjectTests.cs b/src/GitVersion.Git.Managed.Tests/PackedObjectTests.cs new file mode 100644 index 0000000000..e7de0526ef --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/PackedObjectTests.cs @@ -0,0 +1,139 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class PackedObjectTests +{ + [Test] + public void ReadsCommitsFromAPackFile() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "hello\n"); + var firstSha = repository.Commit("first commit"); + repository.WriteFile("file.txt", "hello world\n"); + var secondSha = repository.Commit("second commit"); + + repository.Run("gc", "-q"); + AssertHasNoLooseObjects(repository); + + using var store = repository.OpenObjectStore(); + var commit = store.GetCommit(GitObjectId.Parse(secondSha)); + + commit.Tree.ToString().ShouldBe(repository.RevParse("HEAD^{tree}")); + commit.Parents[0].ToString().ShouldBe(firstSha); + commit.Message.ShouldBe("second commit\n"); + commit.Author.Name.ShouldBe(GitTestRepository.AuthorName); + commit.Committer.When.ShouldBe(repository.CurrentDate); + } + + [Test] + public void ReadsDeltifiedObjectsFromAPackWithDeepDeltaChains() + { + using var repository = new GitTestRepository(); + var commits = CreateDeltaFriendlyHistory(repository); + + repository.Run("repack", "-a", "-d", "-q", "--depth=50", "--window=50"); + AssertHasNoLooseObjects(repository); + + AssertWholeHistoryIsReadable(repository, commits); + } + + [Test] + public void ReadsRefDeltaObjectsFromAPackFile() + { + using var repository = new GitTestRepository(); + var commits = CreateDeltaFriendlyHistory(repository); + + repository.Run("-c", "repack.useDeltaBaseOffset=false", "repack", "-a", "-d", "-q", "--depth=50", "--window=50"); + AssertHasNoLooseObjects(repository); + + AssertWholeHistoryIsReadable(repository, commits); + } + + [Test] + public void ReadsBlobsAndTreesFromAPackFile() + { + using var repository = new GitTestRepository(); + var expectedContent = new StringBuilder(); + for (var i = 0; i < 20; i++) + { + expectedContent.AppendLine($"line {i} of a delta friendly file with some repeating content"); + repository.WriteFile("file.txt", expectedContent.ToString()); + repository.Commit($"commit {i}"); + } + + repository.Run("repack", "-a", "-d", "-q", "--depth=50", "--window=50"); + + using var store = repository.OpenObjectStore(); + using var blob = store.GetBlob(repository.ResolveId("HEAD:file.txt")); + using var reader = new StreamReader(blob); + reader.ReadToEnd().ShouldBe(expectedContent.ToString()); + + var tree = store.GetTree(repository.ResolveId("HEAD^{tree}")); + tree.Entries.Count.ShouldBe(1); + tree.Entries[0].Name.ShouldBe("file.txt"); + } + + [Test] + public void ReadsLooseObjectsCreatedAfterThePack() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "hello\n"); + var packedSha = repository.Commit("packed commit"); + repository.Run("gc", "-q"); + + repository.WriteFile("file.txt", "hello again\n"); + var looseSha = repository.Commit("loose commit"); + + using var store = repository.OpenObjectStore(); + store.GetCommit(GitObjectId.Parse(packedSha)).Message.ShouldBe("packed commit\n"); + store.GetCommit(GitObjectId.Parse(looseSha)).Message.ShouldBe("loose commit\n"); + } + + private static List<(string Sha, string Message)> CreateDeltaFriendlyHistory(GitTestRepository repository) + { + List<(string Sha, string Message)> commits = []; + var content = new StringBuilder(); + + for (var i = 0; i < 30; i++) + { + content.AppendLine($"line {i} of a delta friendly file with some repeating content"); + repository.WriteFile("file.txt", content.ToString()); + var message = $"commit {i}"; + commits.Add((repository.Commit(message), message)); + } + + return commits; + } + + private static void AssertWholeHistoryIsReadable(GitTestRepository repository, List<(string Sha, string Message)> commits) + { + using var store = repository.OpenObjectStore(); + + for (var i = 0; i < commits.Count; i++) + { + var commit = store.GetCommit(GitObjectId.Parse(commits[i].Sha)); + + commit.Message.ShouldBe(commits[i].Message + "\n"); + commit.Parents.Count.ShouldBe(i == 0 ? 0 : 1); + if (i > 0) + { + commit.Parents[0].ToString().ShouldBe(commits[i - 1].Sha); + } + + using var blob = store.GetBlob(repository.ResolveId($"{commits[i].Sha}:file.txt")); + using var reader = new StreamReader(blob); + var lines = reader.ReadToEnd().Split('\n', StringSplitOptions.RemoveEmptyEntries); + lines.Length.ShouldBe(i + 1); + } + } + + private static void AssertHasNoLooseObjects(GitTestRepository repository) + { + var looseObjects = Directory + .EnumerateDirectories(repository.ObjectsDirectory) + .Where(directory => Path.GetFileName(directory) is { Length: 2 }) + .SelectMany(Directory.EnumerateFiles); + + looseObjects.ShouldBeEmpty(); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/PullRequestBranchOperationsTests.cs b/src/GitVersion.Git.Managed.Tests/PullRequestBranchOperationsTests.cs new file mode 100644 index 0000000000..0b3ecac4a7 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/PullRequestBranchOperationsTests.cs @@ -0,0 +1,98 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class PullRequestBranchOperationsTests +{ + private const string HeadTipSha = "0123456789012345678901234567890123456789"; + + [Test] + public void HeadAdvertisementIsNotACandidateTip() + { + // libgit2's remote listing resolves the HEAD symref into an extra entry pointing + // at the default branch tip; it must not count against the single-match check. + var repository = CreateRepository(); + + PullRequestBranchOperations.CreateBranchForPullRequestBranch( + repository, + NullLogger.Instance, + _ => [new("HEAD", HeadTipSha), new("refs/pull/2/merge", HeadTipSha)]); + + repository.References.Received().Add("refs/heads/pull/2/merge", HeadTipSha); + repository.Received().Checkout("refs/heads/pull/2/merge"); + } + + [Test] + public void PeeledAndDuplicateEntriesAreNotCandidateTips() + { + const string tagObjectSha = "1111111111111111111111111111111111111111"; + const string otherCommitSha = "2222222222222222222222222222222222222222"; + var repository = CreateRepository(); + + PullRequestBranchOperations.CreateBranchForPullRequestBranch( + repository, + NullLogger.Instance, + _ => + [ + new("refs/pull/2/merge", HeadTipSha), + new("refs/pull/2/merge", HeadTipSha), // duplicate (e.g. a resolved symref) + new("refs/tags/v1.0.0", tagObjectSha), // annotated tag pointing elsewhere + new("refs/tags/v1.0.0^{}", otherCommitSha) // its peeled entry + ]); + + repository.References.Received().Add("refs/heads/pull/2/merge", HeadTipSha); + } + + [Test] + public void AnnotatedTagIsMatchedThroughItsPeeledEntry() + { + // A detached HEAD at an annotated tag's commit: the tag ref itself carries the + // tag-object sha, so only the peeled "^{}" entry points at the head tip. It must + // fold into the base tag ref and take the tag checkout path. + const string tagObjectSha = "1111111111111111111111111111111111111111"; + var repository = CreateRepository(); + + PullRequestBranchOperations.CreateBranchForPullRequestBranch( + repository, + NullLogger.Instance, + _ => + [ + new("refs/tags/v1.0.0", tagObjectSha), + new("refs/tags/v1.0.0^{}", HeadTipSha) + ]); + + repository.Received().Checkout(HeadTipSha); + } + + [Test] + public void MultipleDistinctCandidateTipsStillFail() + { + var repository = CreateRepository(); + + Should.Throw(() => PullRequestBranchOperations.CreateBranchForPullRequestBranch( + repository, + NullLogger.Instance, + _ => [new("refs/pull/2/merge", HeadTipSha), new("refs/heads/main", HeadTipSha)])) + .Message.ShouldContain("more than one remote tip"); + } + + private static IMutatingGitRepository CreateRepository() + { + var tip = Substitute.For(); + tip.Sha.Returns(HeadTipSha); + + var head = Substitute.For(); + head.Tip.Returns(tip); + + var remote = Substitute.For(); + remote.Name.Returns("origin"); + remote.Url.Returns("https://example.com/repo.git"); + + var remotes = Substitute.For(); + remotes.GetEnumerator().Returns(_ => new List { remote }.GetEnumerator()); + + var repository = Substitute.For(); + repository.Head.Returns(head); + repository.Remotes.Returns(remotes); + return repository; + } +} diff --git a/src/GitVersion.Git.Managed.Tests/TagTests.cs b/src/GitVersion.Git.Managed.Tests/TagTests.cs new file mode 100644 index 0000000000..c879ace17d --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/TagTests.cs @@ -0,0 +1,80 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class TagTests +{ + [Test] + public void ReadsAnAnnotatedTag() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var commitSha = repository.Commit("a commit"); + repository.Run("tag", "-a", "v1.2.3", "-m", "release 1.2.3"); + + var tagSha = repository.RevParse("v1.2.3"); + tagSha.ShouldNotBe(commitSha, "an annotated tag should be its own object"); + + using var store = repository.OpenObjectStore(); + var tag = store.GetTag(GitObjectId.Parse(tagSha)); + + tag.Sha.ToString().ShouldBe(tagSha); + tag.Name.ShouldBe("v1.2.3"); + tag.TargetType.ShouldBe("commit"); + tag.Target.ToString().ShouldBe(commitSha); + tag.Message.ShouldBe("release 1.2.3\n"); + + tag.Tagger.ShouldNotBeNull(); + tag.Tagger.Value.Name.ShouldBe(GitTestRepository.CommitterName); + tag.Tagger.Value.Email.ShouldBe(GitTestRepository.CommitterEmail); + tag.Tagger.Value.When.ShouldBe(repository.CurrentDate); + } + + [Test] + public void ReadsAnAnnotatedTagWithAMultiLineMessage() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("a commit"); + repository.Run("tag", "-a", "v2.0.0", "-m", "subject\n\nbody with details"); + + using var store = repository.OpenObjectStore(); + var tag = store.GetTag(repository.ResolveId("v2.0.0")); + + tag.Message.ShouldBe("subject\n\nbody with details\n"); + } + + [Test] + public void ReadsANestedAnnotatedTag() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + repository.Commit("a commit"); + repository.Run("tag", "-a", "inner", "-m", "inner tag"); + repository.Run("tag", "-a", "outer", "-m", "outer tag", "inner"); + + using var store = repository.OpenObjectStore(); + var outer = store.GetTag(repository.ResolveId("outer")); + + outer.TargetType.ShouldBe("tag"); + outer.Target.ToString().ShouldBe(repository.RevParse("inner")); + } + + [Test] + public void ReadsAnAnnotatedTagFromAPackFile() + { + using var repository = new GitTestRepository(); + repository.WriteFile("file.txt", "content\n"); + var commitSha = repository.Commit("a commit"); + repository.Run("tag", "-a", "v3.0.0", "-m", "packed tag"); + var tagSha = repository.RevParse("v3.0.0"); + + repository.Run("gc", "-q"); + + using var store = repository.OpenObjectStore(); + var tag = store.GetTag(GitObjectId.Parse(tagSha)); + + tag.Name.ShouldBe("v3.0.0"); + tag.Target.ToString().ShouldBe(commitSha); + tag.Message.ShouldBe("packed tag\n"); + } +} diff --git a/src/GitVersion.Git.Managed.Tests/TempDirectory.cs b/src/GitVersion.Git.Managed.Tests/TempDirectory.cs new file mode 100644 index 0000000000..deb0071415 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/TempDirectory.cs @@ -0,0 +1,33 @@ +using GitVersion.Helpers; + +namespace GitVersion.Git.Managed.Tests; + +/// +/// Reserves a unique temporary directory path (without creating it, so it can be used as +/// a target for git clone or git worktree add) and deletes it on dispose. +/// +internal sealed class TempDirectory : IDisposable +{ + public string FullPath { get; } = Path.Combine(Path.GetTempPath(), "managed-git-tests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try + { + if (Directory.Exists(FullPath)) + { + // Git marks pack and loose-object files read-only; a bare recursive delete + // fails on them on Windows. The helper resets attributes before deleting. + FileSystemHelper.Directory.DeleteDirectory(FullPath); + } + } + catch (IOException) + { + // Best effort cleanup of the temporary directory. + } + catch (UnauthorizedAccessException) + { + // Best effort cleanup of the temporary directory. + } + } +} diff --git a/src/GitVersion.Git.Managed.Tests/TreeTests.cs b/src/GitVersion.Git.Managed.Tests/TreeTests.cs new file mode 100644 index 0000000000..f16fcd2fa5 --- /dev/null +++ b/src/GitVersion.Git.Managed.Tests/TreeTests.cs @@ -0,0 +1,90 @@ +namespace GitVersion.Git.Managed.Tests; + +[TestFixture] +public class TreeTests +{ + [Test] + public void ReadsTheRootTreeEntriesMatchingGitLsTree() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "a\n"); + repository.WriteFile("b.txt", "b\n"); + repository.WriteFile("sub/c.txt", "c\n"); + repository.Commit("add files"); + + using var store = repository.OpenObjectStore(); + var treeId = repository.ResolveId("HEAD^{tree}"); + var tree = store.GetTree(treeId); + + tree.Sha.ShouldBe(treeId); + + // git ls-tree prints: SP SP TAB + var expectedEntries = repository + .Run("ls-tree", treeId.ToString()) + .Split('\n') + .Select(line => + { + var parts = line.Split('\t'); + var meta = parts[0].Split(' '); + return (Mode: meta[0], Type: meta[1], Sha: meta[2], Name: parts[1]); + }) + .ToList(); + + tree.Entries.Count.ShouldBe(expectedEntries.Count); + + for (var i = 0; i < expectedEntries.Count; i++) + { + var entry = tree.Entries[i]; + var expected = expectedEntries[i]; + + entry.Name.ShouldBe(expected.Name); + entry.Mode.PadLeft(6, '0').ShouldBe(expected.Mode); + entry.Sha.ToString().ShouldBe(expected.Sha); + entry.IsTree.ShouldBe(expected.Type == "tree"); + } + } + + [Test] + public void ReadsANestedTree() + { + using var repository = new GitTestRepository(); + repository.WriteFile("sub/c.txt", "c\n"); + repository.Commit("add nested file"); + + using var store = repository.OpenObjectStore(); + var rootTree = store.GetTree(repository.ResolveId("HEAD^{tree}")); + + var subEntry = rootTree.FindEntry("sub"); + subEntry.ShouldNotBeNull(); + subEntry.IsTree.ShouldBeTrue(); + + var subTree = store.GetTree(subEntry.Sha); + subTree.Entries.Count.ShouldBe(1); + subTree.Entries[0].Name.ShouldBe("c.txt"); + subTree.Entries[0].IsTree.ShouldBeFalse(); + subTree.Entries[0].Sha.ToString().ShouldBe(repository.RevParse("HEAD:sub/c.txt")); + } + + [Test] + public void FindsANodeUsingTheStreamingReader() + { + using var repository = new GitTestRepository(); + repository.WriteFile("a.txt", "a\n"); + repository.WriteFile("sub/c.txt", "c\n"); + repository.Commit("add files"); + + using var store = repository.OpenObjectStore(); + var treeId = repository.ResolveId("HEAD^{tree}"); + + using (var treeStream = store.GetObject(treeId, "tree")) + { + var node = GitTreeStreamingReader.FindNode(treeStream, "sub"u8); + node.ToString().ShouldBe(repository.RevParse("HEAD:sub")); + } + + using (var treeStream = store.GetObject(treeId, "tree")) + { + GitTreeStreamingReader.FindNode(treeStream, "missing"u8).ShouldBe(GitObjectId.Empty); + } + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/DynamicRepositoryPath.cs b/src/GitVersion.Git.Managed/Adapter/DynamicRepositoryPath.cs new file mode 100644 index 0000000000..8afb5c2afa --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/DynamicRepositoryPath.cs @@ -0,0 +1,51 @@ +using System.IO.Abstractions; +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +/// +/// Computes the path of the dynamically created Git repository used for remote-URL +/// scenarios. The managed backend supplies its own way of checking whether an existing +/// directory is a repository with a matching remote. +/// +internal static class DynamicRepositoryPath +{ + private static readonly char[] DirectorySeparators = ['/', '\\']; + + /// + /// Gets the path of the dynamic repository for , or + /// when no target URL is configured. + /// + /// The file system. + /// The URL of the remote repository. + /// The directory to clone into, or for the temp directory. + /// Checks whether the repository at the given path has a remote with the given URL. + public static string? Get(IFileSystem fileSystem, string? targetUrl, string? clonePath, Func gitRepoHasMatchingRemote) + { + if (targetUrl.IsNullOrWhiteSpace()) + { + return null; + } + + var userTemp = clonePath ?? FileSystemHelper.Path.GetTempPath(); + var repositoryName = targetUrl.Split(DirectorySeparators)[^1].Replace(".git", string.Empty); + var possiblePath = FileSystemHelper.Path.Combine(userTemp, repositoryName); + + // Verify that the existing directory is ok for us to use + if (fileSystem.Directory.Exists(possiblePath) && !gitRepoHasMatchingRemote(possiblePath, targetUrl)) + { + var i = 1; + var originalPath = possiblePath; + bool possiblePathExists; + do + { + possiblePath = $"{originalPath}_{i++}"; + possiblePathExists = fileSystem.Directory.Exists(possiblePath); + } while (possiblePathExists && !gitRepoHasMatchingRemote(possiblePath, targetUrl)); + } + + var repositoryPath = FileSystemHelper.Path.Combine(possiblePath, ".git"); + return repositoryPath; + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedBranch.cs b/src/GitVersion.Git.Managed/Adapter/ManagedBranch.cs new file mode 100644 index 0000000000..fa7c017c21 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedBranch.cs @@ -0,0 +1,34 @@ +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedBranch : IBranch +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Name.Canonical); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Name.Canonical); + + private readonly ManagedGitRepository repository; + + internal ManagedBranch(ReferenceName name, ManagedCommit? tip, ManagedGitRepository repository) + { + Name = name.NotNull(); + Tip = tip; + this.repository = repository.NotNull(); + Commits = ManagedCommitCollection.ReachableFrom(repository, tip); + } + + public ReferenceName Name { get; } + public ICommit? Tip { get; } + public ICommitCollection Commits { get; } + + public bool IsDetachedHead => Name.Canonical.Equals("(no branch)", StringComparison.OrdinalIgnoreCase); + public bool IsRemote => Name.IsRemoteBranch; + public bool IsTracking => this.repository.Session.IsTracking(this); + + public int CompareTo(IBranch? other) => comparerHelper.Compare(this, other); + public bool Equals(IBranch? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as IBranch); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => Name.ToString(); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedBranchCollection.cs b/src/GitVersion.Git.Managed/Adapter/ManagedBranchCollection.cs new file mode 100644 index 0000000000..4af9d29611 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedBranchCollection.cs @@ -0,0 +1,57 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +internal sealed class ManagedBranchCollection(ManagedGitRepository repository) : IBranchCollection +{ + private readonly ManagedGitRepository repository = repository.NotNull(); + + public IEnumerator GetEnumerator() => this.repository.Session.Branches.Cast().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IBranch? this[string name] + { + get + { + name = name.NotNull(); + return this.repository.Session.FindBranch(name); + } + } + + public IEnumerable ExcludeBranches(IEnumerable branchesToExclude) + { + var toExclude = branchesToExclude as IBranch[] ?? [.. branchesToExclude]; + + return this.Where(BranchIsNotExcluded); + + bool BranchIsNotExcluded(IBranch branch) => toExclude.All(branchToExclude => !branch.Equals(branchToExclude)); + } + + public void UpdateTrackedBranch(IBranch branch, string remoteTrackingReferenceName) + { + branch.NotNull(); + remoteTrackingReferenceName.NotNull(); + + if (!remoteTrackingReferenceName.StartsWith(ReferenceName.RemoteTrackingBranchPrefix, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"The reference '{remoteTrackingReferenceName}' is not a remote-tracking branch."); + } + + var remoteAndBranch = remoteTrackingReferenceName[ReferenceName.RemoteTrackingBranchPrefix.Length..]; + var separator = remoteAndBranch.IndexOf('/'); + + if (separator <= 0 || separator == remoteAndBranch.Length - 1) + { + throw new InvalidOperationException($"The reference '{remoteTrackingReferenceName}' is not a remote-tracking branch."); + } + + var remoteName = remoteAndBranch[..separator]; + var mergeReference = ReferenceName.LocalBranchPrefix + remoteAndBranch[(separator + 1)..]; + var friendlyName = branch.Name.Friendly; + + var workingDirectory = this.repository.CliWorkingDirectory; + this.repository.CliMutator.SetConfig(workingDirectory, $"branch.{friendlyName}.remote", remoteName); + this.repository.CliMutator.SetConfig(workingDirectory, $"branch.{friendlyName}.merge", mergeReference); + this.repository.Invalidate(); + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedCommit.cs b/src/GitVersion.Git.Managed/Adapter/ManagedCommit.cs new file mode 100644 index 0000000000..2b99690cb9 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedCommit.cs @@ -0,0 +1,56 @@ +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedCommit : ICommit +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Id); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Sha); + + private readonly GitCommit innerCommit; + private readonly ManagedGitRepository repository; + private readonly Lazy> parentsLazy; + + internal ManagedCommit(GitCommit innerCommit, ManagedGitRepository repository) + { + this.innerCommit = innerCommit; + this.repository = repository.NotNull(); + this.parentsLazy = new(() => + [.. innerCommit.Parents + .Select(parentId => this.repository.Session.TryGetCommit(parentId)) + .Where(parent => parent is not null) + .Cast()]); + Id = new ManagedObjectId(innerCommit.Sha); + Sha = Id.Sha; + When = innerCommit.CommitterWhen; + } + + internal GitObjectId ObjectId => this.innerCommit.Sha; + internal GitObjectId TreeId => this.innerCommit.Tree; + internal GitObjectId? FirstParentId => this.innerCommit.Parents.Count > 0 ? this.innerCommit.Parents[0] : null; + + public IReadOnlyList Parents => this.parentsLazy.Value; + public IObjectId Id { get; } + public string Sha { get; } + public DateTimeOffset When { get; } + public string Message => this.innerCommit.Message; + public bool IsMergeCommit => Parents.Count >= 2; + public IReadOnlyList DiffPaths => this.repository.Session.GetDiffPaths(this); + + public int CompareTo(ICommit? other) => comparerHelper.Compare(this, other); + public bool Equals(ICommit? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as ICommit); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => $"'{Id.ToString(7)}' - {MessageShort}"; + + private string MessageShort + { + get + { + var message = this.innerCommit.Message; + var lineEnd = message.IndexOf('\n'); + return (lineEnd < 0 ? message : message[..lineEnd]).TrimEnd('\r'); + } + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedCommitCollection.cs b/src/GitVersion.Git.Managed/Adapter/ManagedCommitCollection.cs new file mode 100644 index 0000000000..f10b4aecab --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedCommitCollection.cs @@ -0,0 +1,140 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +internal sealed class ManagedCommitCollection : ICommitCollection +{ + private readonly ManagedGitRepository repository; + private readonly bool fromHead; + private readonly IReadOnlyList include; + private readonly IReadOnlyList exclude; + private readonly GitRevisionSortStrategies sort; + private readonly bool firstParentOnly; + + // Memoizes the walk result for the session it was computed against, so repeated + // enumerations are cheap while mutations (which replace the session) stay visible. + private (ManagedRepositorySession Session, IReadOnlyList Commits)? cached; + + private ManagedCommitCollection( + ManagedGitRepository repository, + bool fromHead, + IReadOnlyList include, + IReadOnlyList exclude, + GitRevisionSortStrategies sort, + bool firstParentOnly) + { + this.repository = repository.NotNull(); + this.fromHead = fromHead; + this.include = include; + this.exclude = exclude; + this.sort = sort; + this.firstParentOnly = firstParentOnly; + } + + /// + /// The commits reachable from HEAD in reverse chronological order, matching libgit2's + /// default commit log. HEAD is resolved lazily at enumeration time. + /// + public static ManagedCommitCollection FromHead(ManagedGitRepository repository) => + new(repository, fromHead: true, [], [], GitRevisionSortStrategies.Time, firstParentOnly: false); + + /// + /// The commits reachable from the given tip in reverse chronological order, + /// matching libgit2's Branch.Commits. + /// + public static ManagedCommitCollection ReachableFrom(ManagedGitRepository repository, ManagedCommit? tip) => + new(repository, fromHead: false, tip is null ? [] : [tip.ObjectId], [], GitRevisionSortStrategies.Time, firstParentOnly: false); + + public IEnumerator GetEnumerator() => GetCommits().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IEnumerable GetCommitsPriorTo(DateTimeOffset olderThan) + => this.SkipWhile(c => c.When > olderThan); + + public IEnumerable QueryBy(CommitFilter commitFilter) + { + var includeId = ResolveCommitish(commitFilter.IncludeReachableFrom); + var excludeId = ResolveCommitish(commitFilter.ExcludeReachableFrom); + + return new ManagedCommitCollection( + this.repository, + fromHead: false, + includeId is { } incl ? [incl] : [], + excludeId is { } excl ? [excl] : [], + MapSortStrategies(commitFilter.SortBy), + commitFilter.FirstParentOnly); + } + + private IReadOnlyList GetCommits() + { + var session = this.repository.Session; + + if (this.cached is { } memo && ReferenceEquals(memo.Session, session)) + { + return memo.Commits; + } + + var options = new GitRevisionWalkOptions + { + Sort = this.sort, + FirstParentOnly = this.firstParentOnly + }; + + if (this.fromHead) + { + if (session.HeadTipId is { } headId) + { + options.Include.Add(headId); + } + } + else + { + foreach (var id in this.include) + { + options.Include.Add(id); + } + } + + foreach (var id in this.exclude) + { + options.Exclude.Add(id); + } + + IReadOnlyList commits = options.Include.Count == 0 + ? [] + : [.. session.Walker.Walk(options).Select(session.WrapCommit)]; + + this.cached = (session, commits); + return commits; + } + + private static GitObjectId? ResolveCommitish(ICommitish? item) => + item switch + { + ManagedCommit commit => commit.ObjectId, + ManagedBranch branch => (branch.Tip as ManagedCommit)?.ObjectId, + _ => null + }; + + private static GitRevisionSortStrategies MapSortStrategies(CommitSortStrategies sortBy) + { + var result = GitRevisionSortStrategies.None; + + if (sortBy.HasFlag(CommitSortStrategies.Topological)) + { + result |= GitRevisionSortStrategies.Topological; + } + + if (sortBy.HasFlag(CommitSortStrategies.Time)) + { + result |= GitRevisionSortStrategies.Time; + } + + if (sortBy.HasFlag(CommitSortStrategies.Reverse)) + { + result |= GitRevisionSortStrategies.Reverse; + } + + return result; + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedGitRepository.cs b/src/GitVersion.Git.Managed/Adapter/ManagedGitRepository.cs new file mode 100644 index 0000000000..fc8b1288c5 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedGitRepository.cs @@ -0,0 +1,167 @@ +using GitVersion.Extensions; +using SysPath = System.IO.Path; + +namespace GitVersion.Git; + +internal sealed partial class ManagedGitRepository +{ + private readonly object sessionLock = new(); + private readonly List retiredSessions = []; + private Lazy? sessionLazy; + private string? gitDirectory; + + private ITagCollection? tags; + private IBranchCollection? branches; + private ICommitCollection? commits; + private IRemoteCollection? remotes; + private IReferenceCollection? references; + + internal ManagedRepositorySession Session + { + get + { + var lazy = this.sessionLazy ?? throw new InvalidOperationException("Repository not initialized. Call DiscoverRepository() first."); + return lazy.Value; + } + } + + internal string CliWorkingDirectory => Session.Layout.WorkingDirectory ?? Session.Layout.GitDirectory; + + public string Path => WithTrailingSeparator(Session.Layout.GitDirectory); + + public string WorkingDirectory => + Session.Layout.WorkingDirectory is { } workingDirectory + ? WithTrailingSeparator(workingDirectory) + : throw new InvalidOperationException("The repository is bare: it has no working directory."); + + public bool IsHeadDetached => Session.ReferenceStore.GetHead() is { IsSymbolic: false }; + public bool IsShallow => Session.Layout.IsShallow; + public IBranch Head => Session.GetHead(); + + public ITagCollection Tags => this.tags ??= new ManagedTagCollection(this); + public IBranchCollection Branches => this.branches ??= new ManagedBranchCollection(this); + public ICommitCollection Commits => this.commits ??= ManagedCommitCollection.FromHead(this); + public IRemoteCollection Remotes => this.remotes ??= new ManagedRemoteCollection(this); + public IReferenceCollection References => this.references ??= new ManagedReferenceCollection(this); + + public void DiscoverRepository(string? gitDirectory) + { + this.gitDirectory = gitDirectory; + ResetSession(); + } + + /// + /// Retires the current snapshot of the repository so that changes made through the git CLI + /// (new objects, moved references, updated configuration) become visible on next access. + /// Retired snapshots stay alive until the repository is disposed because wrappers handed + /// out earlier may still be enumerating them. + /// + internal void Invalidate() => ResetSession(); + + public ICommit? FindMergeBase(ICommit commit, ICommit otherCommit) + { + commit = commit.NotNull(); + otherCommit = otherCommit.NotNull(); + + var session = Session; + var mergeBase = session.Walker.FindMergeBase(ResolveObjectId(commit), ResolveObjectId(otherCommit)); + return mergeBase is { } id ? session.GetCommit(id) : null; + } + + private static GitObjectId ResolveObjectId(ICommit commit) => + commit is ManagedCommit managed ? managed.ObjectId : GitObjectId.Parse(commit.Sha); + + public int UncommittedChangesCount() + { + var session = Session; + var headTip = Head.Tip; + + if (headTip is ManagedCommit managedTip) + { + return session.StatusCalculator.CountUncommittedChanges(managedTip.TreeId); + } + + // A brand-new repository without commits: count the untracked files, mirroring the + // libgit2 adapter's behavior (including its "the repo is really dirty" fallback). + try + { + return session.StatusCalculator.CountChangesInEmptyRepository(); + } + catch (Exception) + { + return int.MaxValue; + } + } + + public void Dispose() + { + lock (this.sessionLock) + { + if (this.sessionLazy is { IsValueCreated: true } lazy) + { + lazy.Value.Dispose(); + } + + this.sessionLazy = null; + + foreach (var session in this.retiredSessions) + { + session.Dispose(); + } + + this.retiredSessions.Clear(); + } + } + + private void ResetSession() + { + lock (this.sessionLock) + { + if (this.sessionLazy is { IsValueCreated: true } lazy) + { + this.retiredSessions.Add(lazy.Value); + } + + this.sessionLazy = new(() => new(this, CreateLayout(this.gitDirectory))); + } + } + + private static GitRepositoryLayout CreateLayout(string? gitDirectory) + { + if (gitDirectory.IsNullOrWhiteSpace()) + { + throw new InvalidOperationException("Cannot find the .git directory"); + } + + var path = gitDirectory.TrimEnd('/', '\\'); + + if (!Directory.Exists(path)) + { + throw new DirectoryNotFoundException($"Cannot find the .git directory in path '{gitDirectory}'."); + } + + if (!path.EndsWith(".git", StringComparison.Ordinal)) + { + return GitRepositoryLayout.Discover(path); + } + + // A dynamically cloned repository uses a working directory that is itself named + // '.git' and contains a nested '.git' directory — treat it as a working directory, + // the way libgit2 opens any path that contains a '.git' entry. + var nestedDotGit = SysPath.Combine(path, ".git"); + + if (Directory.Exists(nestedDotGit) || File.Exists(nestedDotGit)) + { + return GitRepositoryLayout.Discover(path); + } + + return SysPath.GetFileName(path) == ".git" + ? GitRepositoryLayout.FromGitDirectory(path, SysPath.GetDirectoryName(path)) + : GitRepositoryLayout.FromGitDirectory(path, workingDirectory: null); + } + + private static string WithTrailingSeparator(string path) => + path.EndsWith(SysPath.DirectorySeparatorChar) || path.EndsWith('/') + ? path + : path + SysPath.DirectorySeparatorChar; +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedGitRepository.mutating.cs b/src/GitVersion.Git.Managed/Adapter/ManagedGitRepository.mutating.cs new file mode 100644 index 0000000000..acfc0214cf --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedGitRepository.mutating.cs @@ -0,0 +1,31 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +internal sealed partial class ManagedGitRepository(ILogger logger, IGitCliMutator cliMutator) : IMutatingGitRepository +{ + private readonly ILogger logger = logger.NotNull(); + + internal IGitCliMutator CliMutator { get; } = cliMutator.NotNull(); + + public void Clone(string? sourceUrl, string? workdirPath, AuthenticationInfo auth) => + CliMutator.Clone(sourceUrl, workdirPath, auth); + + public void Checkout(string commitOrBranchSpec) + { + CliMutator.Checkout(CliWorkingDirectory, commitOrBranchSpec); + Invalidate(); + } + + public void Fetch(string remote, IEnumerable refSpecs, AuthenticationInfo auth, string? logMessage) + { + CliMutator.Fetch(CliWorkingDirectory, remote, refSpecs, auth); + Invalidate(); + } + + public void CreateBranchForPullRequestBranch(AuthenticationInfo auth) => + PullRequestBranchOperations.CreateBranchForPullRequestBranch( + this, + this.logger, + remoteName => CliMutator.ListRemoteReferences(CliWorkingDirectory, remoteName, auth)); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedGitRepositoryInfo.cs b/src/GitVersion.Git.Managed/Adapter/ManagedGitRepositoryInfo.cs new file mode 100644 index 0000000000..d235cad49e --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedGitRepositoryInfo.cs @@ -0,0 +1,98 @@ +using System.IO.Abstractions; +using GitVersion.Extensions; +using SysPath = System.IO.Path; + +namespace GitVersion.Git; + +internal sealed class ManagedGitRepositoryInfo : IGitRepositoryInfo +{ + private readonly IFileSystem fileSystem; + private readonly GitVersionOptions gitVersionOptions; + + private readonly Lazy dynamicGitRepositoryPath; + private readonly Lazy dotGitDirectory; + private readonly Lazy gitRootPath; + private readonly Lazy projectRootDirectory; + + public ManagedGitRepositoryInfo(IFileSystem fileSystem, IOptions options) + { + this.fileSystem = fileSystem.NotNull(); + this.gitVersionOptions = options.NotNull().Value; + + this.dynamicGitRepositoryPath = new(GetDynamicGitRepositoryPath); + this.dotGitDirectory = new(GetDotGitDirectory); + this.gitRootPath = new(GetGitRootPath); + this.projectRootDirectory = new(GetProjectRootDirectory); + } + + public string? DynamicGitRepositoryPath => this.dynamicGitRepositoryPath.Value; + public string? DotGitDirectory => this.dotGitDirectory.Value; + public string? GitRootPath => this.gitRootPath.Value; + public string? ProjectRootDirectory => this.projectRootDirectory.Value; + + private string? GetDynamicGitRepositoryPath() + { + var repositoryInfo = this.gitVersionOptions.RepositoryInfo; + return DynamicRepositoryPath.Get(this.fileSystem, repositoryInfo.TargetUrl, repositoryInfo.ClonePath, GitRepoHasMatchingRemote); + } + + private string? GetDotGitDirectory() => + RepositoryPathResolution.ResolveDotGitDirectory( + this.fileSystem, + DynamicGitRepositoryPath, + this.gitVersionOptions.WorkingDirectory, + static workingDirectory => Discover(workingDirectory)?.GitDirectory); + + private string? GetProjectRootDirectory() => + RepositoryPathResolution.ResolveProjectRootDirectory( + DynamicGitRepositoryPath, + this.gitVersionOptions.WorkingDirectory, + static workingDirectory => Discover(workingDirectory)?.GitDirectory, + static workingDirectory => + { + var repositoryWorkingDirectory = Discover(workingDirectory)?.WorkingDirectory; + if (repositoryWorkingDirectory is null) + { + return null; + } + + // Match libgit2's Info.WorkingDirectory, which carries a trailing directory separator. + return repositoryWorkingDirectory.EndsWith(SysPath.DirectorySeparatorChar) || repositoryWorkingDirectory.EndsWith('/') + ? repositoryWorkingDirectory + : repositoryWorkingDirectory + SysPath.DirectorySeparatorChar; + }); + + private string? GetGitRootPath() => + RepositoryPathResolution.ResolveGitRootPath(DynamicGitRepositoryPath, DotGitDirectory, ProjectRootDirectory); + + /// + /// Discovers the repository containing , returning + /// when the path does not exist — matching libgit2's + /// Repository.Discover, which never walks up from a nonexistent directory. + /// + private static GitRepositoryLayout? Discover(string startPath) => + Directory.Exists(startPath) ? GitRepositoryLayout.TryDiscover(startPath) : null; + + private static bool GitRepoHasMatchingRemote(string possiblePath, string targetUrl) + { + try + { + // Only possiblePath itself may be the repository, matching the libgit2 backend's + // `new Repository(possiblePath)`; discovery walking up the hierarchy would match + // an enclosing repository and hand a nonexistent .git path to the caller. + var layout = GitRepositoryLayout.TryOpen(possiblePath); + if (layout is null) + { + return false; + } + + var configuration = GitConfigurationFile.Load(SysPath.Combine(layout.CommonDirectory, "config")); + return configuration.GetSubsections("remote") + .Any(remoteName => configuration.GetString("remote", remoteName, "url") == targetUrl); + } + catch (Exception) + { + return false; + } + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedObjectId.cs b/src/GitVersion.Git.Managed/Adapter/ManagedObjectId.cs new file mode 100644 index 0000000000..ae38ebb3f7 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedObjectId.cs @@ -0,0 +1,26 @@ +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedObjectId : IObjectId +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Sha); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Sha); + + internal ManagedObjectId(GitObjectId objectId) + { + ObjectId = objectId; + Sha = objectId.ToString(); + } + + internal GitObjectId ObjectId { get; } + + public string Sha { get; } + + public int CompareTo(IObjectId? other) => comparerHelper.Compare(this, other); + public bool Equals(IObjectId? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as IObjectId); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => ToString(7); + public string ToString(int prefixLength) => ObjectId.ToString(prefixLength); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedRefSpec.cs b/src/GitVersion.Git.Managed/Adapter/ManagedRefSpec.cs new file mode 100644 index 0000000000..1ab43f0904 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedRefSpec.cs @@ -0,0 +1,43 @@ +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedRefSpec : IRefSpec +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Specification); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Specification); + + internal ManagedRefSpec(string specification, RefSpecDirection direction) + { + Specification = specification.NotNull(); + Direction = direction; + + // The leading '+' (force) marker is part of the specification but not of the + // source/destination patterns, matching libgit2's refspec parsing. + var body = specification.StartsWith('+') ? specification[1..] : specification; + var separator = body.IndexOf(':'); + + if (separator < 0) + { + Source = body; + Destination = string.Empty; + } + else + { + Source = body[..separator]; + Destination = body[(separator + 1)..]; + } + } + + public string Specification { get; } + public RefSpecDirection Direction { get; } + public string Source { get; } + public string Destination { get; } + + public int CompareTo(IRefSpec? other) => comparerHelper.Compare(this, other); + public bool Equals(IRefSpec? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as IRefSpec); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => Specification; +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedRefSpecCollection.cs b/src/GitVersion.Git.Managed/Adapter/ManagedRefSpecCollection.cs new file mode 100644 index 0000000000..d28806dbd9 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedRefSpecCollection.cs @@ -0,0 +1,9 @@ +namespace GitVersion.Git; + +internal sealed class ManagedRefSpecCollection(IReadOnlyList refSpecs) : IRefSpecCollection +{ + private readonly IReadOnlyList refSpecs = refSpecs ?? throw new ArgumentNullException(nameof(refSpecs)); + + public IEnumerator GetEnumerator() => this.refSpecs.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedReference.cs b/src/GitVersion.Git.Managed/Adapter/ManagedReference.cs new file mode 100644 index 0000000000..44ea9afe1f --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedReference.cs @@ -0,0 +1,37 @@ +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedReference : IReference +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Name.Canonical); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Name.Canonical); + + private readonly GitReference innerReference; + + internal ManagedReference(GitReference reference) + { + this.innerReference = reference.NotNull(); + Name = new ReferenceName(reference.CanonicalName); + + if (!reference.IsSymbolic && reference.ObjectId is { } objectId) + { + ReferenceTargetId = new ManagedObjectId(objectId); + } + } + + public ReferenceName Name { get; } + public IObjectId? ReferenceTargetId { get; } + + public string TargetIdentifier => + this.innerReference.IsSymbolic + ? this.innerReference.SymbolicTargetName! + : this.innerReference.ObjectId!.Value.ToString(); + + public int CompareTo(IReference? other) => comparerHelper.Compare(this, other); + public bool Equals(IReference? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as IReference); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => Name.ToString(); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedReferenceCollection.cs b/src/GitVersion.Git.Managed/Adapter/ManagedReferenceCollection.cs new file mode 100644 index 0000000000..9f84acb084 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedReferenceCollection.cs @@ -0,0 +1,76 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +internal sealed class ManagedReferenceCollection(ManagedGitRepository repository) : IReferenceCollection +{ + private readonly ManagedGitRepository repository = repository.NotNull(); + + public IEnumerator GetEnumerator() => this.repository.Session.References.Cast().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IReference? Head => this["HEAD"]; + + public IReference? this[string name] + { + get + { + name = name.NotNull(); + return this.repository.Session.GetReference(name); + } + } + + public IReference? this[ReferenceName referenceName] => this[referenceName.Canonical]; + + public IEnumerable FromGlob(string prefix) + { + prefix = prefix.NotNull(); + + // GitVersion only uses trailing-wildcard globs ('*' and 'refs/remotes//*'), + // which map onto a canonical-name prefix enumeration. + var referencePrefix = prefix.EndsWith('*') ? prefix[..^1] : prefix; + + if (referencePrefix.Length == 0) + { + referencePrefix = "refs/"; + } + + return this.repository.Session.EnumerateReferences(referencePrefix); + } + + public void Add(string name, string canonicalRefNameOrObject, bool allowOverwrite = false) + { + name = name.NotNull(); + canonicalRefNameOrObject = canonicalRefNameOrObject.NotNull(); + + if (!allowOverwrite && this[name] is not null) + { + throw new InvalidOperationException($"A reference with the name '{name}' already exists."); + } + + var workingDirectory = this.repository.CliWorkingDirectory; + + if (IsObjectId(canonicalRefNameOrObject)) + { + this.repository.CliMutator.UpdateReference(workingDirectory, name, canonicalRefNameOrObject); + } + else + { + this.repository.CliMutator.CreateSymbolicReference(workingDirectory, name, canonicalRefNameOrObject); + } + + this.repository.Invalidate(); + } + + public void UpdateTarget(IReference directRef, IObjectId targetId) + { + directRef.NotNull(); + targetId.NotNull(); + + this.repository.CliMutator.UpdateReference(this.repository.CliWorkingDirectory, directRef.Name.Canonical, targetId.Sha); + this.repository.Invalidate(); + } + + private static bool IsObjectId(string value) => + value.Length is 40 or 64 && value.All(Uri.IsHexDigit); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedRemote.cs b/src/GitVersion.Git.Managed/Adapter/ManagedRemote.cs new file mode 100644 index 0000000000..72ed6cd32e --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedRemote.cs @@ -0,0 +1,35 @@ +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedRemote : IRemote +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Name); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Name); + + private readonly ManagedRefSpecCollection refSpecs; + + internal ManagedRemote(string name, string url, IReadOnlyList fetchRefSpecs, IReadOnlyList pushRefSpecs) + { + Name = name.NotNull(); + Url = url.NotNull(); + this.refSpecs = new( + [ + .. fetchRefSpecs.Select(IRefSpec (spec) => new ManagedRefSpec(spec, RefSpecDirection.Fetch)), + .. pushRefSpecs.Select(IRefSpec (spec) => new ManagedRefSpec(spec, RefSpecDirection.Push)) + ]); + } + + public string Name { get; } + public string Url { get; } + + public IEnumerable FetchRefSpecs => this.refSpecs.Where(x => x.Direction == RefSpecDirection.Fetch); + public IEnumerable PushRefSpecs => this.refSpecs.Where(x => x.Direction == RefSpecDirection.Push); + + public int CompareTo(IRemote? other) => comparerHelper.Compare(this, other); + public bool Equals(IRemote? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as IRemote); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => Name; +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedRemoteCollection.cs b/src/GitVersion.Git.Managed/Adapter/ManagedRemoteCollection.cs new file mode 100644 index 0000000000..4c09892591 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedRemoteCollection.cs @@ -0,0 +1,35 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +internal sealed class ManagedRemoteCollection(ManagedGitRepository repository) : IRemoteCollection +{ + private readonly ManagedGitRepository repository = repository.NotNull(); + + public IEnumerator GetEnumerator() => this.repository.Session.Remotes.Cast().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IRemote? this[string name] + { + get + { + name = name.NotNull(); + return this.repository.Session.Remotes.FirstOrDefault(remote => remote.Name == name); + } + } + + public void Remove(string remoteName) + { + remoteName = remoteName.NotNull(); + this.repository.CliMutator.RemoveRemote(this.repository.CliWorkingDirectory, remoteName); + this.repository.Invalidate(); + } + + public void Update(string remoteName, string refSpec) + { + remoteName = remoteName.NotNull(); + refSpec = refSpec.NotNull(); + this.repository.CliMutator.AddConfig(this.repository.CliWorkingDirectory, $"remote.{remoteName}.fetch", refSpec); + this.repository.Invalidate(); + } +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedRepositorySession.cs b/src/GitVersion.Git.Managed/Adapter/ManagedRepositorySession.cs new file mode 100644 index 0000000000..11e2fd100e --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedRepositorySession.cs @@ -0,0 +1,259 @@ +using System.Collections.Concurrent; +using GitVersion.Extensions; + +namespace GitVersion.Git; + +/// +/// An immutable snapshot of the repository state (objects, references, configuration) with +/// per-snapshot wrapper caches. Mutating operations replace the current session on the owning +/// so that changes made through the git CLI become visible. +/// +internal sealed class ManagedRepositorySession : IDisposable +{ + private const string RemoteSectionName = "remote"; + + private readonly ManagedGitRepository repository; + private readonly ConcurrentDictionary commits = new(); + private readonly ConcurrentDictionary> diffPathsCache = new(); + private readonly Lazy configuration; + private readonly Lazy> branches; + private readonly Lazy> branchesByCanonicalName; + private readonly Lazy> tags; + private readonly Lazy> references; + private readonly Lazy> remotes; + + public ManagedRepositorySession(ManagedGitRepository repository, GitRepositoryLayout layout) + { + this.repository = repository.NotNull(); + Layout = layout.NotNull(); + ObjectStore = layout.CreateObjectStore(); + ReferenceStore = layout.CreateReferenceStore(); + // Shallow boundaries are part of the snapshot: the walker grafts the commits listed + // in .git/shallow as parentless, the way git and libgit2 treat them. + Walker = new(ObjectStore, layout.ReadShallowCommits().ToHashSet()); + TreeDiff = new(ObjectStore); + StatusCalculator = new(layout, ObjectStore); + this.configuration = new(() => GitConfigurationFile.Load(Path.Combine(Layout.CommonDirectory, "config"))); + this.branches = new(ReadBranches); + this.branchesByCanonicalName = new(() => this.branches.Value.ToDictionary(branch => branch.Name.Canonical, StringComparer.Ordinal)); + this.tags = new(() => [.. EnumerateStoreReferencesInLibGit2Order("refs/tags/").Select(reference => new ManagedTag(reference, this.repository))]); + this.references = new(() => [.. EnumerateStoreReferencesInLibGit2Order("refs/").Select(reference => new ManagedReference(reference))]); + this.remotes = new(ReadRemotes); + } + + public GitRepositoryLayout Layout { get; } + public GitObjectStore ObjectStore { get; } + public GitReferenceStore ReferenceStore { get; } + public GitRevisionWalker Walker { get; } + public GitTreeDiff TreeDiff { get; } + public GitStatusCalculator StatusCalculator { get; } + public GitConfigurationFile Configuration => this.configuration.Value; + + public IReadOnlyList Branches => this.branches.Value; + public IReadOnlyList Tags => this.tags.Value; + public IReadOnlyList References => this.references.Value; + public IReadOnlyList Remotes => this.remotes.Value; + + public GitObjectId? HeadTipId => ReferenceStore.Resolve("HEAD")?.ObjectId; + + public ManagedBranch GetHead() + { + var head = ReferenceStore.GetHead() + ?? throw new InvalidOperationException("The repository has no HEAD reference."); + + if (head.IsSymbolic) + { + var targetName = head.SymbolicTargetName!; + var tipId = ReferenceStore.Resolve(targetName)?.ObjectId; + var tip = tipId is { } id ? TryGetCommit(id) : null; + return this.branchesByCanonicalName.Value.GetValueOrDefault(targetName) + ?? new ManagedBranch(new(targetName), tip, this.repository); + } + + // Detached HEAD: libgit2 exposes it as a branch named "(no branch)". + var commit = head.ObjectId is { } commitId ? TryGetCommit(commitId) : null; + return new(new("(no branch)"), commit, this.repository); + } + + public ManagedBranch? FindBranch(string name) + { + var byCanonicalName = this.branchesByCanonicalName.Value; + + if (name.StartsWith("refs/", StringComparison.Ordinal)) + { + return byCanonicalName.GetValueOrDefault(name); + } + + return byCanonicalName.GetValueOrDefault(ReferenceName.LocalBranchPrefix + name) + ?? byCanonicalName.GetValueOrDefault(ReferenceName.RemoteTrackingBranchPrefix + name); + } + + public ManagedReference? GetReference(string canonicalName) => + ReferenceStore.GetReference(canonicalName) is { } reference ? new ManagedReference(reference) : null; + + public IEnumerable EnumerateReferences(string prefix) => + EnumerateStoreReferencesInLibGit2Order(prefix).Select(reference => new ManagedReference(reference)); + + /// + /// Enumerates references the way libgit2's refdb iterates them: loose references first + /// (in name order), then the packed references that are not shadowed by a loose one + /// (in name order). The store itself yields git's globally sorted for-each-ref order. + /// + private IEnumerable EnumerateStoreReferencesInLibGit2Order(string prefix) + { + var storeReferences = ReferenceStore.EnumerateReferences(prefix).ToList(); + return storeReferences.Where(reference => !reference.IsPacked) + .Concat(storeReferences.Where(reference => reference.IsPacked)); + } + + public ManagedCommit GetCommit(GitObjectId objectId) => + TryGetCommit(objectId) + ?? throw new InvalidOperationException($"The commit '{objectId}' does not exist in the repository."); + + public ManagedCommit? TryGetCommit(GitObjectId objectId) + { + if (this.commits.TryGetValue(objectId, out var existing)) + { + return existing; + } + + // The walker's parsed-commit cache is the single parse per session; walks + // load most commits first, so wrapping reuses those parses. + return Walker.TryLoadCommit(objectId) is { } innerCommit + ? WrapCommit(innerCommit) + : null; + } + + public ManagedCommit WrapCommit(GitCommit innerCommit) => + this.commits.GetOrAdd(innerCommit.Sha, _ => new(innerCommit, this.repository)); + + public IReadOnlyList GetDiffPaths(ManagedCommit commit) => + this.diffPathsCache.GetOrAdd(commit.ObjectId, _ => + { + GitObjectId? parentTreeId = commit.FirstParentId is { } parentId ? TryGetCommit(parentId)?.TreeId : null; + return TreeDiff.GetChangedPaths(parentTreeId, commit.TreeId); + }); + + /// + /// Returns what libgit2 exposes as a tag's target sha: for an annotated tag the sha of + /// the object the tag annotation points at (a single dereference), for a lightweight + /// tag the ref target itself. + /// + public string GetTagTargetSha(GitReference reference) + { + var targetId = reference.ObjectId + ?? throw new InvalidOperationException($"The tag '{reference.CanonicalName}' does not point at an object."); + + if (!ObjectStore.TryGetObject(targetId, out var stream, out var objectType)) + { + throw new InvalidOperationException($"The object '{targetId}' does not exist in the repository."); + } + + using (stream) + { + return objectType == GitObjectTypes.Tag + ? GitTagReader.Read(stream, targetId).Target.ToString() + : targetId.ToString(); + } + } + + public ICommit PeelToCommit(GitReference reference) + { + if (reference.PeeledObjectId is { } peeledId) + { + return GetCommit(peeledId); + } + + var targetId = reference.ObjectId + ?? throw new InvalidOperationException($"The reference '{reference.CanonicalName}' does not point at an object."); + + while (true) + { + if (!ObjectStore.TryGetObject(targetId, out var stream, out var objectType)) + { + throw new InvalidOperationException($"The object '{targetId}' does not exist in the repository."); + } + + switch (objectType) + { + case GitObjectTypes.Commit: + stream.Dispose(); + return GetCommit(targetId); + case GitObjectTypes.Tag: + using (stream) + { + targetId = GitTagReader.Read(stream, targetId).Target; + } + + break; + default: + stream.Dispose(); + throw new InvalidOperationException($"The reference '{reference.CanonicalName}' does not ultimately point at a commit."); + } + } + } + + public bool IsTracking(ManagedBranch branch) + { + if (branch.IsRemote || branch.IsDetachedHead) + { + return false; + } + + var friendlyName = branch.Name.Friendly; + var remoteName = Configuration.GetString("branch", friendlyName, "remote"); + var mergeReference = Configuration.GetString("branch", friendlyName, "merge"); + + if (remoteName is null || mergeReference is null) + { + return false; + } + + if (remoteName == ".") + { + return ReferenceStore.GetReference(mergeReference) is not null; + } + + if (!mergeReference.StartsWith(ReferenceName.LocalBranchPrefix, StringComparison.Ordinal)) + { + return false; + } + + var upstream = $"{ReferenceName.RemoteTrackingBranchPrefix}{remoteName}/{mergeReference[ReferenceName.LocalBranchPrefix.Length..]}"; + return ReferenceStore.GetReference(upstream) is not null; + } + + public void Dispose() => ObjectStore.Dispose(); + + private IReadOnlyList ReadBranches() + { + // libgit2's branch iterator walks the whole refdb once (loose first, then packed) + // and filters to branch namespaces, so local and remote-tracking branches interleave + // in that reference order. + var result = new List(); + + foreach (var reference in EnumerateStoreReferencesInLibGit2Order("refs/")) + { + if (!reference.CanonicalName.StartsWith(ReferenceName.LocalBranchPrefix, StringComparison.Ordinal) + && !reference.CanonicalName.StartsWith(ReferenceName.RemoteTrackingBranchPrefix, StringComparison.Ordinal)) + { + continue; + } + + var objectId = reference.ObjectId + ?? (reference.IsSymbolic ? ReferenceStore.Resolve(reference.CanonicalName)?.ObjectId : null); + var tip = objectId is { } id ? TryGetCommit(id) : null; + result.Add(new(new(reference.CanonicalName), tip, this.repository)); + } + + return result; + } + + private IReadOnlyList ReadRemotes() => + [.. Configuration.GetSubsections(RemoteSectionName) + .Select(name => new ManagedRemote( + name, + Configuration.GetString(RemoteSectionName, name, "url") ?? string.Empty, + Configuration.GetAll(RemoteSectionName, name, "fetch"), + Configuration.GetAll(RemoteSectionName, name, "push")))]; +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedTag.cs b/src/GitVersion.Git.Managed/Adapter/ManagedTag.cs new file mode 100644 index 0000000000..2d59298efb --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedTag.cs @@ -0,0 +1,37 @@ +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +internal sealed class ManagedTag : ITag +{ + private static readonly LambdaEqualityHelper equalityHelper = new(x => x.Name.Canonical); + private static readonly LambdaKeyComparer comparerHelper = new(x => x.Name.Canonical); + + private readonly Lazy commitLazy; + private readonly Lazy targetShaLazy; + + internal ManagedTag(GitReference reference, ManagedGitRepository repository) + { + this.commitLazy = new(() => repository.NotNull().Session.PeelToCommit(reference.NotNull())); + this.targetShaLazy = new(() => repository.NotNull().Session.GetTagTargetSha(reference.NotNull())); + Name = new(reference.CanonicalName); + } + + public ReferenceName Name { get; } + + /// + /// Matches libgit2: for an annotated tag this is the sha of the object the tag + /// annotation points at (one dereference, usually the commit); for a lightweight + /// tag it is the ref target itself. + /// + public string TargetSha => this.targetShaLazy.Value; + + public ICommit Commit => this.commitLazy.Value; + + public int CompareTo(ITag? other) => comparerHelper.Compare(this, other); + public bool Equals(ITag? other) => equalityHelper.Equals(this, other); + public override bool Equals(object? obj) => Equals(obj as ITag); + public override int GetHashCode() => equalityHelper.GetHashCode(this); + public override string ToString() => Name.ToString(); +} diff --git a/src/GitVersion.Git.Managed/Adapter/ManagedTagCollection.cs b/src/GitVersion.Git.Managed/Adapter/ManagedTagCollection.cs new file mode 100644 index 0000000000..781f5220cf --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/ManagedTagCollection.cs @@ -0,0 +1,11 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +internal sealed class ManagedTagCollection(ManagedGitRepository repository) : ITagCollection +{ + private readonly ManagedGitRepository repository = repository.NotNull(); + + public IEnumerator GetEnumerator() => this.repository.Session.Tags.Cast().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/GitVersion.Git.Managed/Adapter/RepositoryPathResolution.cs b/src/GitVersion.Git.Managed/Adapter/RepositoryPathResolution.cs new file mode 100644 index 0000000000..a5cd1436e9 --- /dev/null +++ b/src/GitVersion.Git.Managed/Adapter/RepositoryPathResolution.cs @@ -0,0 +1,87 @@ +using System.IO.Abstractions; +using GitVersion.Extensions; +using GitVersion.Helpers; + +namespace GitVersion.Git; + +/// +/// The part of resolving the repository paths GitVersion works with +/// () that is independent of Git plumbing: the dot-git +/// directory with its worktree handling, the project root, and the git root. The managed +/// backend supplies its own repository discovery and working-directory resolution. +/// +internal static class RepositoryPathResolution +{ + private const string DotGitDirectoryNotFoundMessage = "Cannot find the .git directory"; + + /// + /// Resolves the dot-git directory: the dynamic repository path when one is configured, + /// otherwise the discovered git directory — mapped to the main repository's directory + /// when it is a linked worktree's. + /// + /// The file system. + /// The dynamic repository path, when configured. + /// The working directory to discover from. + /// Discovers the git directory containing a path, or . + public static string? ResolveDotGitDirectory( + IFileSystem fileSystem, + string? dynamicGitRepositoryPath, + string workingDirectory, + Func discoverGitDirectory) + { + var gitDirectory = (!dynamicGitRepositoryPath.IsNullOrWhiteSpace() + ? dynamicGitRepositoryPath + : discoverGitDirectory(workingDirectory)) + ?.TrimEnd('/', '\\'); + + if (string.IsNullOrEmpty(gitDirectory)) + { + throw new DirectoryNotFoundException(DotGitDirectoryNotFoundMessage); + } + + var directoryInfo = fileSystem.Directory.GetParent(gitDirectory) ?? throw new DirectoryNotFoundException(DotGitDirectoryNotFoundMessage); + return gitDirectory.Contains(FileSystemHelper.Path.Combine(".git", "worktrees")) + ? fileSystem.Directory.GetParent(directoryInfo.FullName)?.FullName + : gitDirectory; + } + + /// + /// Resolves the project root: the working directory itself for dynamic repositories, + /// otherwise the discovered repository's working directory (with a trailing separator, + /// as libgit2 reports it) — for bare repositories, which have none. + /// Throws only when repository discovery itself fails. + /// + /// The dynamic repository path, when configured. + /// The working directory to discover from. + /// Discovers the git directory containing a path, or . + /// Resolves the working directory of the repository containing a path, or when the repository is bare. + public static string? ResolveProjectRootDirectory( + string? dynamicGitRepositoryPath, + string workingDirectory, + Func discoverGitDirectory, + Func resolveRepositoryWorkingDirectory) + { + if (!dynamicGitRepositoryPath.IsNullOrWhiteSpace()) + { + return workingDirectory; + } + + if (discoverGitDirectory(workingDirectory).IsNullOrEmpty()) + { + throw new DirectoryNotFoundException(DotGitDirectoryNotFoundMessage); + } + + // Discovery succeeded; a null working directory means the repository is bare. + return resolveRepositoryWorkingDirectory(workingDirectory); + } + + /// + /// Resolves the git root: the dot-git directory for dynamic repositories, otherwise + /// the project root. + /// + /// The dynamic repository path, when configured. + /// The resolved dot-git directory. + /// The resolved project root. + public static string? ResolveGitRootPath(string? dynamicGitRepositoryPath, string? dotGitDirectory, string? projectRootDirectory) => + !dynamicGitRepositoryPath.IsNullOrWhiteSpace() ? dotGitDirectory : projectRootDirectory; +} diff --git a/src/GitVersion.Git.Managed/CommandLine/GitCliExecutor.cs b/src/GitVersion.Git.Managed/CommandLine/GitCliExecutor.cs new file mode 100644 index 0000000000..728ef5f735 --- /dev/null +++ b/src/GitVersion.Git.Managed/CommandLine/GitCliExecutor.cs @@ -0,0 +1,118 @@ +using GitVersion.Extensions; + +namespace GitVersion.Git; + +/// Executes the git command-line executable and captures its output. +internal interface IGitCliExecutor +{ + /// Runs git with the given arguments and returns the completed result without throwing on failure. + GitCliResult Execute(string? workingDirectory, IReadOnlyList arguments); +} + +/// The outcome of a single git invocation. +internal sealed record GitCliResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool IsSuccess => ExitCode == 0; +} + +internal sealed class GitCliExecutor(ILogger logger) : IGitCliExecutor +{ + private readonly ILogger logger = logger.NotNull(); + + public GitCliResult Execute(string? workingDirectory, IReadOnlyList arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = "git", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + if (workingDirectory != null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + // Deterministic, non-interactive invocations: never prompt for credentials + // and keep any parsed output locale-independent. + startInfo.Environment["GIT_TERMINAL_PROMPT"] = "0"; + startInfo.Environment["LC_ALL"] = "C"; + + // The repository is targeted via the working directory alone, like libgit2 + // targets it via the discovered path. Inherited repo-targeting variables + // (git exports GIT_DIR when running hooks) would redirect the invocation + // to a different repository than the one the managed reader discovered. + string[] repoTargetingVariables = + [ + "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", + "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES" + ]; + foreach (var variable in repoTargetingVariables) + { + startInfo.Environment.Remove(variable); + } + + this.logger.LogDebug("Executing 'git {Arguments}' in '{WorkingDirectory}'", FormatArgumentsForLog(arguments), workingDirectory); + + using var process = new Process(); + process.StartInfo = startInfo; + + var standardOutput = new StringBuilder(); + var standardError = new StringBuilder(); + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) + { + standardOutput.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + standardError.AppendLine(e.Data); + } + }; + + try + { + process.Start(); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or PlatformNotSupportedException) + { + throw new InvalidOperationException( + "The 'git' executable could not be started. Ensure Git is installed and available on the PATH.", ex); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + var result = new GitCliResult(process.ExitCode, standardOutput.ToString(), standardError.ToString()); + if (!result.IsSuccess) + { + this.logger.LogDebug("'git {Arguments}' exited with code {ExitCode}: {StandardError}", FormatArgumentsForLog(arguments), result.ExitCode, result.StandardError); + } + + return result; + } + + /// + /// Joins the arguments for logging, redacting credential-bearing values such as the + /// per-invocation http.*.extraHeader=Authorization: … configuration. + /// + private static string FormatArgumentsForLog(IReadOnlyList arguments) => + string.Join(' ', arguments.Select(static argument => + { + const string marker = ".extraHeader=Authorization:"; + var index = argument.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + return index < 0 ? argument : argument[..(index + marker.Length)] + " ********"; + })); +} diff --git a/src/GitVersion.Git.Managed/CommandLine/GitCliMutator.cs b/src/GitVersion.Git.Managed/CommandLine/GitCliMutator.cs new file mode 100644 index 0000000000..5c8d961649 --- /dev/null +++ b/src/GitVersion.Git.Managed/CommandLine/GitCliMutator.cs @@ -0,0 +1,182 @@ +using System.Text.RegularExpressions; +using GitVersion.Extensions; + +namespace GitVersion.Git; + +/// +/// Performs the mutating and network Git operations GitVersion needs (repository normalization) +/// by invoking the git command-line executable instead of libgit2. +/// +internal interface IGitCliMutator +{ + void Clone(string? sourceUrl, string? workdirPath, AuthenticationInfo auth); + void Fetch(string workingDirectory, string remote, IEnumerable refSpecs, AuthenticationInfo auth); + void Checkout(string workingDirectory, string commitOrBranchSpec); + IReadOnlyList ListRemoteReferences(string workingDirectory, string remoteName, AuthenticationInfo auth); + void SetConfig(string workingDirectory, string key, string value); + void AddConfig(string workingDirectory, string key, string value); + void RemoveRemote(string workingDirectory, string remoteName); + void UpdateReference(string workingDirectory, string name, string targetSha); + void CreateSymbolicReference(string workingDirectory, string name, string targetReferenceName); +} + +internal sealed partial class GitCliMutator(ILogger logger, IGitCliExecutor executor) : IGitCliMutator +{ + private readonly ILogger logger = logger.NotNull(); + private readonly IGitCliExecutor executor = executor.NotNull(); + + public void Clone(string? sourceUrl, string? workdirPath, AuthenticationInfo auth) + { + ArgumentNullException.ThrowIfNull(sourceUrl); + ArgumentNullException.ThrowIfNull(workdirPath); + + var arguments = new List(); + AddAuthentication(arguments, sourceUrl, auth); + arguments.AddRange(["clone", "--no-checkout", sourceUrl, workdirPath]); + + var result = this.executor.Execute(null, arguments); + ThrowOnFailure(result, isNetworkOperation: true); + this.logger.LogInformation("Cloned repository '{SourceUrl}' into '{WorkdirPath}'", sourceUrl, workdirPath); + } + + public void Fetch(string workingDirectory, string remote, IEnumerable refSpecs, AuthenticationInfo auth) + { + var arguments = new List(); + AddAuthentication(arguments, sourceUrl: null, auth); + arguments.AddRange(["fetch", remote]); + arguments.AddRange(refSpecs); + + var result = this.executor.Execute(workingDirectory, arguments); + ThrowOnFailure(result, isNetworkOperation: true); + } + + public void Checkout(string workingDirectory, string commitOrBranchSpec) + { + // libgit2's Commands.Checkout attaches HEAD when given a canonical local branch name, + // while `git checkout refs/heads/x` would detach — use the branch shortname instead. + // `--no-guess` suppresses git's remote-branch DWIM, which libgit2 does not have. + const string localBranchPrefix = "refs/heads/"; + var spec = commitOrBranchSpec.StartsWith(localBranchPrefix, StringComparison.Ordinal) + ? commitOrBranchSpec[localBranchPrefix.Length..] + : commitOrBranchSpec; + + var result = this.executor.Execute(workingDirectory, ["checkout", "--no-guess", spec]); + ThrowOnFailure(result); + } + + public IReadOnlyList ListRemoteReferences(string workingDirectory, string remoteName, AuthenticationInfo auth) + { + var arguments = new List(); + AddAuthentication(arguments, sourceUrl: null, auth); + arguments.AddRange(["ls-remote", "--quiet", remoteName]); + + var result = this.executor.Execute(workingDirectory, arguments); + ThrowOnFailure(result, isNetworkOperation: true); + + var references = new List(); + foreach (var line in result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separator = line.IndexOf('\t'); + if (separator <= 0) + { + continue; + } + + var sha = line[..separator]; + var name = line[(separator + 1)..]; + + // Skip the symbolic HEAD advertisement: it is not part of libgit2's ListReferences + // result that this replaces, and it would show up as a duplicate of the branch it + // points at. Peeled entries ("^{}") are kept: they carry an annotated tag's + // commit sha, which PullRequestBranchOperations folds into the base tag ref. + if (!name.StartsWith("refs/", StringComparison.Ordinal)) + { + continue; + } + + references.Add(new(name, sha)); + } + + return references; + } + + public void SetConfig(string workingDirectory, string key, string value) => + ThrowOnFailure(this.executor.Execute(workingDirectory, ["config", key, value])); + + public void AddConfig(string workingDirectory, string key, string value) => + ThrowOnFailure(this.executor.Execute(workingDirectory, ["config", "--add", key, value])); + + public void RemoveRemote(string workingDirectory, string remoteName) => + ThrowOnFailure(this.executor.Execute(workingDirectory, ["remote", "remove", remoteName])); + + public void UpdateReference(string workingDirectory, string name, string targetSha) => + ThrowOnFailure(this.executor.Execute(workingDirectory, ["update-ref", name, targetSha])); + + public void CreateSymbolicReference(string workingDirectory, string name, string targetReferenceName) => + ThrowOnFailure(this.executor.Execute(workingDirectory, ["symbolic-ref", name, targetReferenceName])); + + private static void AddAuthentication(List arguments, string? sourceUrl, AuthenticationInfo auth) + { + if (auth.Username.IsNullOrWhiteSpace()) + { + return; + } + + // Same credential semantics as the libgit2 UsernamePasswordCredentials provider: basic auth + // over HTTPS. Passed per invocation via configuration so it is never persisted and never + // appears in the remote URL. ArgumentList passes it without shell interpolation, and the + // value is not echoed by git in error output. + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{auth.Username}:{auth.Password ?? string.Empty}")); + var scope = sourceUrl == null ? "http" : $"http.{sourceUrl}"; + arguments.AddRange(["-c", $"{scope}.extraHeader=Authorization: Basic {credentials}"]); + } + + private static void ThrowOnFailure(GitCliResult result, bool isNetworkOperation = false) + { + if (result.IsSuccess) + { + return; + } + + var message = result.StandardError; + if (IsLockContention(message)) + { + throw new LockedFileException(message); + } + + // HTTP status classification only applies to commands that talk to a remote; + // a local ref or pathspec can legitimately contain "401"/"404" in its name. + if (isNetworkOperation) + { + if (message.Contains("401") || message.Contains("Authentication failed", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Unauthorized: Incorrect username/password"); + } + + if (message.Contains("403")) + { + throw new InvalidOperationException("Forbidden: Possibly Incorrect username/password"); + } + + // "Repository not found" is GitHub's server-side banner; other hosts produce + // git's own "fatal: repository '' not found" with the URL in between. + if (message.Contains("404") + || message.Contains("Repository not found", StringComparison.OrdinalIgnoreCase) + || RepositoryNotFoundRegex().IsMatch(message)) + { + throw new InvalidOperationException("Not found: The repository was not found"); + } + } + + throw new InvalidOperationException($"Git command failed with exit code {result.ExitCode}: {message}"); + } + + private static bool IsLockContention(string message) => + (message.Contains(".lock", StringComparison.Ordinal) && message.Contains("Unable to create", StringComparison.OrdinalIgnoreCase)) + || message.Contains("could not lock", StringComparison.OrdinalIgnoreCase) + || message.Contains("cannot lock ref", StringComparison.OrdinalIgnoreCase) + || message.Contains("Another git process seems to be running", StringComparison.OrdinalIgnoreCase); + + [GeneratedRegex(@"repository '[^']*' not found", RegexOptions.IgnoreCase)] + private static partial Regex RepositoryNotFoundRegex(); +} diff --git a/src/GitVersion.Git.Managed/CommandLine/GitRemoteReference.cs b/src/GitVersion.Git.Managed/CommandLine/GitRemoteReference.cs new file mode 100644 index 0000000000..93629dbf18 --- /dev/null +++ b/src/GitVersion.Git.Managed/CommandLine/GitRemoteReference.cs @@ -0,0 +1,4 @@ +namespace GitVersion.Git; + +/// A remote reference as advertised by the remote (e.g. by git ls-remote). +internal sealed record GitRemoteReference(string CanonicalName, string TargetSha); diff --git a/src/GitVersion.Git.Managed/CommandLine/PullRequestBranchOperations.cs b/src/GitVersion.Git.Managed/CommandLine/PullRequestBranchOperations.cs new file mode 100644 index 0000000000..d0dfd36a60 --- /dev/null +++ b/src/GitVersion.Git.Managed/CommandLine/PullRequestBranchOperations.cs @@ -0,0 +1,107 @@ +namespace GitVersion.Git; + +/// +/// The Git-plumbing-independent part of turning a pull-request ref into a real local +/// branch: finding the remote tip that points at HEAD and checking out a fake local +/// branch for it. The managed backend drives this with its own way of listing remote +/// references. +/// +internal static class PullRequestBranchOperations +{ + /// + /// Creates a local branch that tracks the pull-request ref pointing at the current HEAD tip. + /// + /// The repository to operate on. + /// The logger. + /// Lists the references advertised by the remote with the given name. + public static void CreateBranchForPullRequestBranch( + IMutatingGitRepository repository, + ILogger logger, + Func> listRemoteReferences) + { + logger.LogInformation("Fetching remote refs to see if there is a pull request ref"); + + // An unborn HEAD (empty repository or a failed checkout) has no tip to match remote + // refs against, so there is nothing to normalize — same behavior as the libgit2 backend. + if (repository.Head.Tip == null) + { + return; + } + + var headTipSha = repository.Head.Tip.Sha; + var remote = repository.Remotes.Single(); + var (canonicalName, targetSha) = GetPullRequestReference(logger, remote, listRemoteReferences(remote.Name), headTipSha); + var referenceName = ReferenceName.Parse(canonicalName); + logger.LogInformation("Found remote tip '{CanonicalName}' pointing at the commit '{HeadTipSha}'.", canonicalName, headTipSha); + + if (referenceName.IsTag) + { + logger.LogInformation("Checking out tag '{CanonicalName}'", canonicalName); + repository.Checkout(targetSha); + } + else if (referenceName.IsPullRequest) + { + var fakeBranchName = canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/"); + + logger.LogInformation("Creating fake local branch '{FakeBranchName}'.", fakeBranchName); + repository.References.Add(fakeBranchName, headTipSha); + + logger.LogInformation("Checking local branch '{FakeBranchName}' out.", fakeBranchName); + repository.Checkout(fakeBranchName); + } + else + { + var message = $"Remote tip '{canonicalName}' from remote '{remote.Url}' doesn't look like a valid pull request."; + throw new WarningException(message); + } + } + + private static (string CanonicalName, string TargetSha) GetPullRequestReference( + ILogger logger, + IRemote remote, + IReadOnlyList remoteTips, + string headTipSha) + { + var remoteRefsList = string.Join(SysEnv.NewLine, remoteTips.Select(r => r.CanonicalName)); + logger.LogInformation(""" + Remote Refs: + {RemoteRefsList} + """, remoteRefsList); + // The advertised HEAD symref (which libgit2 resolves into a duplicate of the + // default branch's direct reference) is not a candidate tip: it would make the + // same commit appear twice and trip the single-match check below. Peeled "^{}" + // entries are folded into their base ref instead of contributing their own + // candidate: an annotated tag's ref carries the tag-object sha, so only the + // peeled entry's commit sha can ever match a detached HEAD tip. Grouping here + // keeps both backends' candidate sets identical regardless of how their + // listing behaves. + const string peeledSuffix = "^{}"; + var refs = remoteTips + .Where(r => r.CanonicalName.StartsWith("refs/", StringComparison.Ordinal)) + .GroupBy(r => r.CanonicalName.EndsWith(peeledSuffix, StringComparison.Ordinal) + ? r.CanonicalName[..^peeledSuffix.Length] + : r.CanonicalName) + .Select(g => new GitRemoteReference( + g.Key, + (g.FirstOrDefault(r => r.CanonicalName.EndsWith(peeledSuffix, StringComparison.Ordinal)) ?? g.First()).TargetSha)) + .Where(r => r.TargetSha == headTipSha) + .ToList(); + + switch (refs.Count) + { + case 0: + { + var message = $"Couldn't find any remote tips from remote '{remote.Url}' pointing at the commit '{headTipSha}'."; + throw new WarningException(message); + } + case > 1: + { + var names = string.Join(", ", refs.Select(r => r.CanonicalName)); + var message = $"Found more than one remote tip from remote '{remote.Url}' pointing at the commit '{headTipSha}'. Unable to determine which one to use ({names})."; + throw new WarningException(message); + } + } + + return (refs[0].CanonicalName, refs[0].TargetSha); + } +} diff --git a/src/GitVersion.Git.Managed/DeltaInstruction.cs b/src/GitVersion.Git.Managed/DeltaInstruction.cs new file mode 100644 index 0000000000..0586bf89aa --- /dev/null +++ b/src/GitVersion.Git.Managed/DeltaInstruction.cs @@ -0,0 +1,43 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Enumerates the instruction types which can be found in a deltified stream. +/// +/// +internal 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 +} + +/// +/// Represents an instruction in a deltified stream. +/// +/// +internal struct DeltaInstruction +{ + /// + /// 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/GitVersion.Git.Managed/DeltaStreamReader.cs b/src/GitVersion.Git.Managed/DeltaStreamReader.cs new file mode 100644 index 0000000000..456490530f --- /dev/null +++ b/src/GitVersion.Git.Managed/DeltaStreamReader.cs @@ -0,0 +1,96 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Reads delta instructions from a . +/// +/// +internal 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) + { + var next = stream.ReadByte(); + + if (next == -1) + { + return null; + } + + var 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 + { + ReadCopyInstruction(stream, instruction, ref value); + } + + return value; + } + + private static void ReadCopyInstruction(Stream stream, byte instruction, ref DeltaInstruction value) + { + if ((instruction & 0b0000_0001) != 0) + { + value.Offset |= ReadByteOrThrow(stream); + } + + if ((instruction & 0b0000_0010) != 0) + { + value.Offset |= ReadByteOrThrow(stream) << 8; + } + + if ((instruction & 0b0000_0100) != 0) + { + value.Offset |= ReadByteOrThrow(stream) << 16; + } + + if ((instruction & 0b0000_1000) != 0) + { + value.Offset |= ReadByteOrThrow(stream) << 24; + } + + if ((instruction & 0b0001_0000) != 0) + { + value.Size = ReadByteOrThrow(stream); + } + + if ((instruction & 0b0010_0000) != 0) + { + value.Size |= ReadByteOrThrow(stream) << 8; + } + + if ((instruction & 0b0100_0000) != 0) + { + value.Size |= ReadByteOrThrow(stream) << 16; + } + + // Size zero is automatically converted to 0x10000. + if (value.Size == 0) + { + value.Size = 0x10000; + } + } + + private static int ReadByteOrThrow(Stream stream) + { + var value = stream.ReadByte(); + return value == -1 + ? throw new EndOfStreamException("The delta stream ended in the middle of a copy instruction.") + : value; + } +} diff --git a/src/GitVersion.Git.Managed/Diff/GitTreeDiff.cs b/src/GitVersion.Git.Managed/Diff/GitTreeDiff.cs new file mode 100644 index 0000000000..a03d50646b --- /dev/null +++ b/src/GitVersion.Git.Managed/Diff/GitTreeDiff.cs @@ -0,0 +1,182 @@ +namespace GitVersion.Git; + +/// +/// Computes the paths changed between two trees: a recursive tree-vs-tree diff with no +/// rename detection, matching libgit2's default TreeChanges path list (which is +/// what GitVersion's ICommit.DiffPaths exposes). Identical subtrees are skipped +/// by object id; entries align using git's canonical tree order, where directory names +/// compare as if they had a trailing slash. +/// +internal sealed class GitTreeDiff(GitObjectStore objectStore) +{ + private readonly GitObjectStore objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); + + /// + /// Returns the repository-relative paths which differ between the two trees, + /// in path order. Either side may be to diff against the + /// empty tree (e.g. for root commits). + /// + /// The object id of the old tree, or for the empty tree. + /// The object id of the new tree, or for the empty tree. + /// The changed paths. + public IReadOnlyList GetChangedPaths(GitObjectId? oldTreeId, GitObjectId? newTreeId) + { + if (Nullable.Equals(oldTreeId, newTreeId)) + { + return []; + } + + var paths = new List(); + DiffTrees(LoadTree(oldTreeId), LoadTree(newTreeId), prefix: "", paths); + return paths; + } + + /// + /// Flattens a tree into all of its blob paths and their entries. + /// + /// The object id of the tree, or for the empty tree. + /// A dictionary of repository-relative path to tree entry. + public Dictionary FlattenTree(GitObjectId? treeId) + { + var files = new Dictionary(StringComparer.Ordinal); + + if (treeId is { } id) + { + Flatten(this.objectStore.GetTree(id), prefix: "", files); + } + + return files; + } + + private void Flatten(GitTree tree, string prefix, Dictionary files) + { + foreach (var entry in tree.Entries) + { + if (entry.IsTree) + { + Flatten(this.objectStore.GetTree(entry.Sha), prefix + entry.Name + "/", files); + } + else + { + files[prefix + entry.Name] = entry; + } + } + } + + private GitTree? LoadTree(GitObjectId? treeId) => treeId is { } id ? this.objectStore.GetTree(id) : null; + + private void DiffTrees(GitTree? oldTree, GitTree? newTree, string prefix, List paths) + { + var oldEntries = oldTree?.Entries ?? []; + var newEntries = newTree?.Entries ?? []; + var oldIndex = 0; + var newIndex = 0; + + while (oldIndex < oldEntries.Count || newIndex < newEntries.Count) + { + var comparison = (oldIndex < oldEntries.Count, newIndex < newEntries.Count) switch + { + (true, false) => -1, + (false, true) => 1, + _ => CompareTreeNames(oldEntries[oldIndex], newEntries[newIndex]) + }; + + if (comparison < 0) + { + EmitAll(oldEntries[oldIndex++], prefix, paths); + } + else if (comparison > 0) + { + EmitAll(newEntries[newIndex++], prefix, paths); + } + else + { + DiffAlignedEntries(oldEntries[oldIndex++], newEntries[newIndex++], prefix, paths); + } + } + } + + private void DiffAlignedEntries(GitTreeEntry oldEntry, GitTreeEntry newEntry, string prefix, List paths) + { + if (oldEntry.Sha == newEntry.Sha && oldEntry.Mode == newEntry.Mode) + { + return; + } + + if (oldEntry.IsTree && newEntry.IsTree) + { + if (oldEntry.Sha != newEntry.Sha) + { + DiffTrees(LoadTree(oldEntry.Sha), LoadTree(newEntry.Sha), prefix + oldEntry.Name + "/", paths); + } + + return; + } + + if (!oldEntry.IsTree && !newEntry.IsTree) + { + paths.Add(prefix + oldEntry.Name); + return; + } + + // The name changed type between a file and a directory: the file path sorts + // before the paths inside the directory. + var (file, tree) = oldEntry.IsTree ? (newEntry, oldEntry) : (oldEntry, newEntry); + paths.Add(prefix + file.Name); + EmitAll(tree, prefix, paths); + } + + private void EmitAll(GitTreeEntry entry, string prefix, List paths) + { + if (!entry.IsTree) + { + paths.Add(prefix + entry.Name); + return; + } + + var tree = this.objectStore.GetTree(entry.Sha); + + foreach (var child in tree.Entries) + { + EmitAll(child, prefix + entry.Name + "/", paths); + } + } + + private static int CompareTreeNames(GitTreeEntry left, GitTreeEntry right) + { + // Git sorts tree entries by their raw unsigned bytes, not by the decoded + // strings: UTF-16 comparison inverts e.g. U+E000..U+FFFF versus surrogate + // pairs, and Latin-1-fallback names do not round-trip through the string. + var leftName = left.NameBytes.Span; + var rightName = right.NameBytes.Span; + + // Entries with the same name align even when their type differs, so a + // file-to-directory change on one name is detected as such. + if (leftName.SequenceEqual(rightName)) + { + return 0; + } + + var commonLength = Math.Min(leftName.Length, rightName.Length); + var comparison = leftName[..commonLength].SequenceCompareTo(rightName[..commonLength]); + + if (comparison != 0) + { + return comparison; + } + + // One name is a prefix of the other. Git's canonical tree order: directory + // names sort as if they ended with '/', while an exhausted file name ends. + return NextByte(leftName, commonLength, left.IsTree) - NextByte(rightName, commonLength, right.IsTree); + } + + private static int NextByte(ReadOnlySpan name, int index, bool isTree) + { + if (index < name.Length) + { + return name[index]; + } + + return isTree ? (byte)'/' : 0; + } +} diff --git a/src/GitVersion.Git.Managed/FileHelpers.cs b/src/GitVersion.Git.Managed/FileHelpers.cs new file mode 100644 index 0000000000..02f0d72190 --- /dev/null +++ b/src/GitVersion.Git.Managed/FileHelpers.cs @@ -0,0 +1,34 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git; + +internal static class FileHelpers +{ + /// + /// Opens the file with a given path for reading, if it exists. + /// + /// The path to the file. + /// The stream opened over the file, if the file exists. + /// if the file exists; otherwise . + public static bool TryOpen(string path, [NotNullWhen(true)] out FileStream? stream) + { + if (!File.Exists(path)) + { + stream = null; + return false; + } + + try + { + stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + return true; + } + catch (IOException) + { + stream = null; + return false; + } + } +} diff --git a/src/GitVersion.Git.Managed/GitCommit.cs b/src/GitVersion.Git.Managed/GitCommit.cs new file mode 100644 index 0000000000..fe3cb23d51 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitCommit.cs @@ -0,0 +1,95 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Represents a Git commit, as stored in the Git object database. The signatures and the +/// message are kept as raw bytes and decoded lazily: history walks load every traversed +/// commit but only ever need the parents and the committer timestamp. +/// +internal sealed class GitCommit +{ + private GitSignature? author; + private GitSignature? committer; + private string? message; + + /// + /// Gets the which uniquely identifies this commit. + /// + public required GitObjectId Sha { get; init; } + + /// + /// Gets the of the root tree of this commit. + /// + public required GitObjectId Tree { get; init; } + + /// + /// Gets the parents of this commit, in order. + /// + public required IReadOnlyList Parents { get; init; } + + /// + /// Gets the committer timestamp. Parsed eagerly: the revision walk orders commits by it + /// and must not pay for decoding the full signatures or the message. + /// + public required DateTimeOffset CommitterWhen { get; init; } + + /// + /// Gets the raw bytes of the author header value. + /// + public required byte[] RawAuthor { get; init; } + + /// + /// Gets the raw bytes of the committer header value. + /// + public required byte[] RawCommitter { get; init; } + + /// + /// Gets the raw bytes of the commit message. + /// + public required byte[] RawMessage { get; init; } + + /// + /// Gets the value of the commit's encoding header, if present. + /// + public string? EncodingName { get; init; } + + /// + /// Gets the author of this commit, decoded on first access. + /// + public GitSignature Author => this.author ??= GitSignature.Parse(RawAuthor, EncodingName); + + /// + /// Gets the committer of this commit, decoded on first access. + /// + public GitSignature Committer => this.committer ??= GitSignature.Parse(RawCommitter, EncodingName); + + /// + /// Gets the full commit message, decoded on first access honoring the commit's encoding header. + /// + public string Message => this.message ??= GitTextDecoder.Decode(RawMessage, EncodingName); + + /// + /// Creates a copy of this commit with no parents. Used to apply shallow-clone grafts: + /// commits at the .git/shallow boundary are exposed as parentless the way git and + /// libgit2 expose them, regardless of the parent headers stored in the object. + /// + /// The parentless copy, or this instance when it already has no parents. + public GitCommit WithoutParents() => + Parents.Count == 0 + ? this + : new() + { + Sha = Sha, + Tree = Tree, + Parents = [], + CommitterWhen = CommitterWhen, + RawAuthor = RawAuthor, + RawCommitter = RawCommitter, + RawMessage = RawMessage, + EncodingName = EncodingName + }; + + /// + public override string ToString() => $"Git Commit: {Sha}"; +} diff --git a/src/GitVersion.Git.Managed/GitCommitReader.cs b/src/GitVersion.Git.Managed/GitCommitReader.cs new file mode 100644 index 0000000000..d5d8370b17 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitCommitReader.cs @@ -0,0 +1,119 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers; + +namespace GitVersion.Git; + +/// +/// Reads a object. +/// +internal static class GitCommitReader +{ + /// + /// Reads a object from a . + /// + /// A which contains the commit in its text representation. + /// The of the commit. + /// The . + public static GitCommit Read(Stream stream, GitObjectId sha) + { + ArgumentNullException.ThrowIfNull(stream); + + var buffer = ArrayPool.Shared.Rent(checked((int)stream.Length)); + + try + { + var span = buffer.AsSpan(0, (int)stream.Length); + stream.ReadExactly(span); + + return Read(span, sha); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Reads a object from a . + /// + /// A which contains the commit in its text representation. + /// The of the commit. + /// The . + public static GitCommit Read(ReadOnlySpan commit, GitObjectId sha) + { + GitObjectId? tree = null; + List parents = []; + ReadOnlySpan authorLine = default; + ReadOnlySpan committerLine = default; + var hasAuthor = false; + var hasCommitter = false; + string? encodingName = null; + + var buffer = commit; + + while (!buffer.IsEmpty) + { + var lineEnd = buffer.IndexOf((byte)'\n'); + if (lineEnd < 0) + { + lineEnd = buffer.Length; + } + + var line = buffer[..lineEnd]; + buffer = buffer[Math.Min(lineEnd + 1, buffer.Length)..]; + + if (line.IsEmpty) + { + // An empty line separates the headers from the commit message. + break; + } + + if (line[0] == ' ') + { + // A continuation of the previous (multi-line) header, such as gpgsig; skip. + continue; + } + + if (line.StartsWith("tree "u8)) + { + tree = GitObjectId.ParseHex(line["tree "u8.Length..]); + } + else if (line.StartsWith("parent "u8)) + { + parents.Add(GitObjectId.ParseHex(line["parent "u8.Length..])); + } + else if (line.StartsWith("author "u8)) + { + authorLine = line["author "u8.Length..]; + hasAuthor = true; + } + else if (line.StartsWith("committer "u8)) + { + committerLine = line["committer "u8.Length..]; + hasCommitter = true; + } + else if (line.StartsWith("encoding "u8)) + { + encodingName = GitTextDecoder.Decode(line["encoding "u8.Length..]); + } + } + + if (tree is null || !hasAuthor || !hasCommitter) + { + throw new GitObjectStoreException($"The commit {sha} is malformed: a tree, author or committer header is missing."); + } + + return new() + { + Sha = sha, + Tree = tree.Value, + Parents = parents, + CommitterWhen = GitSignature.ParseWhen(committerLine), + RawAuthor = authorLine.ToArray(), + RawCommitter = committerLine.ToArray(), + RawMessage = buffer.ToArray(), + EncodingName = encodingName + }; + } +} diff --git a/src/GitVersion.Git.Managed/GitMultiPackIndexReader.cs b/src/GitVersion.Git.Managed/GitMultiPackIndexReader.cs new file mode 100644 index 0000000000..15d637436e --- /dev/null +++ b/src/GitVersion.Git.Managed/GitMultiPackIndexReader.cs @@ -0,0 +1,209 @@ +using System.Buffers.Binary; + +namespace GitVersion.Git; + +/// +/// Reads a Git multi-pack-index file (format version 1), which indexes the objects +/// of multiple pack files in a single file. +/// +/// +internal sealed class GitMultiPackIndexReader : IDisposable +{ + private const uint PackNamesChunkId = 0x504E414D; // "PNAM" + private const uint OidFanoutChunkId = 0x4F494446; // "OIDF" + private const uint OidLookupChunkId = 0x4F49444C; // "OIDL" + private const uint ObjectOffsetsChunkId = 0x4F4F4646; // "OOFF" + + private const int HeaderLength = 12; + private const int ChunkTableEntryLength = 12; + + private readonly FileStream stream; + private readonly int oidLength; + private readonly int[] fanoutTable = new int[256]; + private readonly Dictionary chunks = []; + + /// + /// Initializes a new instance of the class. + /// + /// A which points to the multi-pack-index file. Ownership is transferred to the reader. + public GitMultiPackIndexReader(FileStream stream) + { + this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); + + try + { + Span header = stackalloc byte[HeaderLength]; + stream.SafeFileHandle.ReadExactlyAt(0, header); + + if (!header[..4].SequenceEqual("MIDX"u8)) + { + throw new GitObjectStoreException("The multi-pack-index file has an invalid signature."); + } + + var version = header[4]; + if (version != 1) + { + throw new GitObjectStoreException($"Multi-pack-index version {version} is not supported; only version 1 is supported."); + } + + this.oidLength = header[5] switch + { + 1 => GitObjectId.Sha1Size, + 2 => GitObjectId.Sha256Size, + _ => throw new GitObjectStoreException($"The multi-pack-index object id version {header[5]} is not supported.") + }; + + var chunkCount = header[6]; + var baseFileCount = header[7]; + var packCount = BinaryPrimitives.ReadUInt32BigEndian(header[8..12]); + + if (baseFileCount != 0) + { + throw new NotSupportedException("Incremental multi-pack-index chains are not supported."); + } + + ReadChunkTable(chunkCount); + ReadFanoutTable(); + PackNames = ReadPackNames((int)packCount); + } + catch + { + this.stream.Dispose(); + throw; + } + } + + /// + /// Gets the names of the pack files indexed by this multi-pack-index, without their + /// file extension, in the order used by . + /// + public IReadOnlyList PackNames { get; } + + /// + /// Looks up an object in the multi-pack-index. + /// + /// The Git object id of the object to look up. + /// + /// If found, the index (into ) of the pack which contains the object, + /// and the offset of the object within that pack; otherwise, . + /// + public (int PackIndex, long Offset)? GetOffset(GitObjectId objectId) + { + if (objectId.HashLength != this.oidLength) + { + return null; + } + + Span objectName = stackalloc byte[this.oidLength]; + objectId.CopyTo(objectName); + + var low = objectName[0] == 0 ? 0 : this.fanoutTable[objectName[0] - 1]; + var high = this.fanoutTable[objectName[0]] - 1; + + var oidLookupOffset = GetChunk(OidLookupChunkId).Offset; + + Span current = stackalloc byte[this.oidLength]; + var order = -1; + var i = 0; + + while (low <= high) + { + i = (low + high) / 2; + + this.stream.SafeFileHandle.ReadExactlyAt(oidLookupOffset + ((long)this.oidLength * i), current); + order = current.SequenceCompareTo(objectName); + + if (order < 0) + { + low = i + 1; + } + else if (order > 0) + { + high = i - 1; + } + else + { + break; + } + } + + if (order != 0) + { + return null; + } + + Span entry = stackalloc byte[8]; + this.stream.SafeFileHandle.ReadExactlyAt(GetChunk(ObjectOffsetsChunkId).Offset + (8L * i), entry); + + var packIndex = BinaryPrimitives.ReadUInt32BigEndian(entry[..4]); + var offset = BinaryPrimitives.ReadUInt32BigEndian(entry[4..8]); + + if ((offset & 0x8000_0000) != 0) + { + throw new NotSupportedException("Multi-pack-index files with large (>= 2 GiB) object offsets are not supported."); + } + + return ((int)packIndex, offset); + } + + /// + public void Dispose() => this.stream.Dispose(); + + private (long Offset, long Length) GetChunk(uint chunkId) => + this.chunks.TryGetValue(chunkId, out var chunk) + ? chunk + : throw new GitObjectStoreException($"The multi-pack-index file is missing the required 0x{chunkId:X8} chunk."); + + private void ReadChunkTable(int chunkCount) + { + // The chunk table has chunkCount + 1 entries; the terminating entry (id 0) records + // the offset at which the chunks end, which yields each chunk's length. + var table = new byte[(chunkCount + 1) * ChunkTableEntryLength]; + this.stream.SafeFileHandle.ReadExactlyAt(HeaderLength, table); + + for (var i = 0; i < chunkCount; i++) + { + var entry = table.AsSpan(i * ChunkTableEntryLength, ChunkTableEntryLength); + var chunkId = BinaryPrimitives.ReadUInt32BigEndian(entry[..4]); + var chunkOffset = BinaryPrimitives.ReadInt64BigEndian(entry[4..12]); + var nextChunkOffset = BinaryPrimitives.ReadInt64BigEndian(table.AsSpan(((i + 1) * ChunkTableEntryLength) + 4, 8)); + this.chunks[chunkId] = (chunkOffset, nextChunkOffset - chunkOffset); + } + } + + private void ReadFanoutTable() + { + Span fanout = stackalloc byte[256 * 4]; + this.stream.SafeFileHandle.ReadExactlyAt(GetChunk(OidFanoutChunkId).Offset, fanout); + + for (var i = 0; i < 256; i++) + { + this.fanoutTable[i] = BinaryPrimitives.ReadInt32BigEndian(fanout.Slice(4 * i, 4)); + } + } + + private string[] ReadPackNames(int packCount) + { + var (chunkOffset, chunkLength) = GetChunk(PackNamesChunkId); + + var contents = new byte[chunkLength]; + this.stream.SafeFileHandle.ReadExactlyAt(chunkOffset, contents); + + var names = new string[packCount]; + var span = contents.AsSpan(); + + for (var i = 0; i < packCount; i++) + { + var nameEnd = span.IndexOf((byte)0); + if (nameEnd < 0) + { + throw new GitObjectStoreException("The multi-pack-index pack name chunk is malformed."); + } + + names[i] = Path.GetFileNameWithoutExtension(Encoding.UTF8.GetString(span[..nameEnd])); + span = span[(nameEnd + 1)..]; + } + + return names; + } +} diff --git a/src/GitVersion.Git.Managed/GitObjectId.cs b/src/GitVersion.Git.Managed/GitObjectId.cs new file mode 100644 index 0000000000..52cbddd334 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitObjectId.cs @@ -0,0 +1,232 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace GitVersion.Git; + +/// +/// Identifies an object stored in the Git object database. The id of an object is the hash +/// of its contents. This type is hash-agnostic: it stores up to 32 bytes together with the +/// hash length, supporting both SHA-1 (20 bytes) and SHA-256 (32 bytes) repositories. +/// +/// +internal struct GitObjectId : IEquatable +{ + /// The length, in bytes, of a SHA-1 object id. + public const int Sha1Size = 20; + + /// The length, in bytes, of a SHA-256 object id. + public const int Sha256Size = 32; + + private const string HexDigits = "0123456789abcdef"; + private static readonly byte[] ReverseHexDigits = BuildReverseHexDigits(); + + [SuppressMessage("Minor Code Smell", "S3459:Unassigned members should be removed", Justification = "The inline array is written through its span conversion.")] + private HashBuffer value; + private byte length; + private string? sha; + + [InlineArray(Sha256Size)] + private struct HashBuffer + { + private byte element; + } + + /// + /// Gets a which represents an empty . + /// + public static GitObjectId Empty => default; + + /// + /// Gets the length, in bytes, of the hash represented by this object id. Zero for . + /// + public readonly int HashLength => this.length; + + public static bool operator ==(GitObjectId left, GitObjectId right) => left.Equals(right); + + public static bool operator !=(GitObjectId left, GitObjectId right) => !left.Equals(right); + + /// + /// Parses a sequence of byte values as a . + /// + /// The raw hash bytes. Must be exactly 20 (SHA-1) or 32 (SHA-256) bytes in length. + /// A . + public static GitObjectId Parse(ReadOnlySpan value) + { + if (value.Length is not (Sha1Size or Sha256Size)) + { + throw new ArgumentException($"The length should be {Sha1Size} or {Sha256Size} bytes, but was {value.Length}.", nameof(value)); + } + + var objectId = default(GitObjectId); + Span bytes = objectId.value; + value.CopyTo(bytes); + objectId.length = (byte)value.Length; + return objectId; + } + + /// + /// Parses the hexadecimal representation of a . + /// + /// The hexadecimal representation. Must be 40 (SHA-1) or 64 (SHA-256) characters long. + /// A . + public static GitObjectId Parse(ReadOnlySpan value) + { + if (value.Length is not (2 * Sha1Size or 2 * Sha256Size)) + { + throw new ArgumentException($"The length should be {2 * Sha1Size} or {2 * Sha256Size} characters, but was {value.Length}.", nameof(value)); + } + + var objectId = default(GitObjectId); + Span bytes = objectId.value; + var byteCount = value.Length / 2; + + for (var i = 0; i < byteCount; i++) + { + var high = GetHexValue(value[2 * i]); + var low = GetHexValue(value[(2 * i) + 1]); + + bytes[i] = (byte)((high << 4) + low); + } + + objectId.length = (byte)byteCount; + return objectId; + } + + /// + /// Parses the hexadecimal representation of a . + /// + /// The hexadecimal representation. Must be 40 (SHA-1) or 64 (SHA-256) characters long. + /// A . + public static GitObjectId Parse(string value) + { + var objectId = Parse(value.AsSpan()); + objectId.sha = value.ToLowerInvariant(); + return objectId; + } + + /// + /// Parses the ASCII-encoded hexadecimal representation of a . + /// + /// The hexadecimal representation, encoded in ASCII. Must be 40 (SHA-1) or 64 (SHA-256) bytes long. + /// A . + public static GitObjectId ParseHex(ReadOnlySpan value) + { + if (value.Length is not (2 * Sha1Size or 2 * Sha256Size)) + { + throw new ArgumentException($"The length should be {2 * Sha1Size} or {2 * Sha256Size} characters, but was {value.Length}.", nameof(value)); + } + + var objectId = default(GitObjectId); + Span bytes = objectId.value; + var byteCount = value.Length / 2; + + for (var i = 0; i < byteCount; i++) + { + var high = GetHexValue((char)value[2 * i]); + var low = GetHexValue((char)value[(2 * i) + 1]); + + bytes[i] = (byte)((high << 4) + low); + } + + objectId.length = (byte)byteCount; + return objectId; + } + + /// + public override readonly bool Equals(object? obj) => obj is GitObjectId other && Equals(other); + + /// + public readonly bool Equals(GitObjectId other) + { + ReadOnlySpan mine = this.value; + ReadOnlySpan theirs = other.value; + return this.length == other.length && mine.SequenceEqual(theirs); + } + + /// + public override readonly int GetHashCode() + { + ReadOnlySpan bytes = this.value; + return BinaryPrimitives.ReadInt32LittleEndian(bytes); + } + + /// + /// Gets a which represents the first two bytes of this . + /// + /// The first two bytes of this , in big-endian order. + public readonly ushort AsUInt16() + { + ReadOnlySpan bytes = this.value; + return BinaryPrimitives.ReadUInt16BigEndian(bytes); + } + + /// + /// Copies the byte representation of this to a . + /// + /// The memory to which to copy this . Must be at least bytes long. + public readonly void CopyTo(Span destination) + { + ReadOnlySpan bytes = this.value; + bytes[..this.length].CopyTo(destination); + } + + /// + /// Returns the hexadecimal representation of this object id. + /// + /// + public override string ToString() => this.sha ??= ToString(this.length * 2); + + /// + /// Returns the first hexadecimal characters of this object id. + /// + /// The number of hexadecimal characters to return. + /// A hexadecimal prefix of this object id. + public readonly string ToString(int length) + { + ArgumentOutOfRangeException.ThrowIfNegative(length); + ArgumentOutOfRangeException.ThrowIfGreaterThan(length, this.length * 2); + + Span chars = stackalloc char[length]; + ReadOnlySpan bytes = this.value; + + for (var i = 0; i < chars.Length; i++) + { + var b = bytes[i >> 1]; + chars[i] = HexDigits[(i & 1) == 0 ? b >> 4 : b & 0x0F]; + } + + return new string(chars); + } + + private static int GetHexValue(char c) + { + var index = c - '0'; + if (index < 0 || index >= ReverseHexDigits.Length || (index != 0 && ReverseHexDigits[index] == 0 && c != '0')) + { + throw new FormatException($"The character '{c}' is not a valid hexadecimal digit."); + } + + return ReverseHexDigits[index]; + } + + private static byte[] BuildReverseHexDigits() + { + var bytes = new byte['f' - '0' + 1]; + + for (var i = 0; i < 10; i++) + { + bytes[i] = (byte)i; + } + + for (var i = 10; i < 16; i++) + { + bytes[i + 'a' - '0' - 0x0a] = (byte)i; + bytes[i + 'A' - '0' - 0x0a] = (byte)i; + } + + return bytes; + } +} diff --git a/src/GitVersion.Git.Managed/GitObjectStore.cs b/src/GitVersion.Git.Managed/GitObjectStore.cs new file mode 100644 index 0000000000..5c36c21286 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitObjectStore.cs @@ -0,0 +1,284 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git; + +/// +/// Provides read-only access to the objects stored in a Git object database +/// (a .git/objects directory), without relying on native libraries or +/// the git executable. +/// +/// +/// Objects are looked up in the multi-pack-index (when present), in the +/// individual pack files, among the loose objects, and finally in the alternate +/// object databases listed in objects/info/alternates (one level deep). +/// +internal sealed class GitObjectStore : IDisposable +{ + private readonly List alternates = []; + private readonly Dictionary packsByName = new(StringComparer.Ordinal); + private readonly Lazy packNames; + private readonly Lazy multiPackIndex; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the Git object directory (usually .git/objects). + public GitObjectStore(string objectDirectory) : this(objectDirectory, followAlternates: true) + { + } + + private GitObjectStore(string objectDirectory, bool followAlternates) + { + ArgumentNullException.ThrowIfNull(objectDirectory); + + ObjectDirectory = Path.GetFullPath(objectDirectory); + this.packNames = new(LoadPackNames); + this.multiPackIndex = new(LoadMultiPackIndex); + + if (followAlternates) + { + LoadAlternates(); + } + } + + /// + /// Gets the full path to the Git object directory. + /// + public string ObjectDirectory { get; } + + /// + /// Reads the commit with the given object id. + /// + /// The object id of the commit. + /// The . + public GitCommit GetCommit(GitObjectId objectId) + { + using var stream = GetObject(objectId, GitObjectTypes.Commit); + return GitCommitReader.Read(stream, objectId); + } + + /// + /// Reads the tree with the given object id. + /// + /// The object id of the tree. + /// The . + public GitTree GetTree(GitObjectId objectId) + { + using var stream = GetObject(objectId, GitObjectTypes.Tree); + return GitTreeReader.Read(stream, objectId); + } + + /// + /// Reads the annotated tag with the given object id. + /// + /// The object id of the annotated tag. + /// The . + public GitTag GetTag(GitObjectId objectId) + { + using var stream = GetObject(objectId, GitObjectTypes.Tag); + return GitTagReader.Read(stream, objectId); + } + + /// + /// Opens a stream over the contents of the blob with the given object id. + /// + /// The object id of the blob. + /// A over the blob contents. + public Stream GetBlob(GitObjectId objectId) => GetObject(objectId, GitObjectTypes.Blob); + + /// + /// Opens a stream over the contents of the object with the given object id. + /// + /// The object id of the object to retrieve. + /// The expected object type (commit, tree, blob or tag). + /// A over the object contents. + /// The object could not be found. + public Stream GetObject(GitObjectId objectId, string objectType) => + TryGetObjectCore(objectId, objectType)?.Stream + ?? throw new GitObjectStoreException($"An object of type '{objectType}' with id '{objectId}' could not be found.") { ObjectNotFound = true }; + + /// + /// Attempts to open a stream over the contents of the object with the given object id. + /// + /// The object id of the object to retrieve. + /// The expected object type (commit, tree, blob or tag). + /// If found, receives a over the object contents. + /// if the object was found; otherwise, . + public bool TryGetObject(GitObjectId objectId, string objectType, [NotNullWhen(true)] out Stream? stream) + { + stream = TryGetObjectCore(objectId, objectType)?.Stream; + return stream is not null; + } + + /// + /// Attempts to open a stream over the contents of the object with the given object id, + /// without knowing its object type up front. + /// + /// The object id of the object to retrieve. + /// If found, receives a over the object contents. + /// If found, receives the object type (commit, tree, blob or tag). + /// if the object was found; otherwise, . + public bool TryGetObject(GitObjectId objectId, [NotNullWhen(true)] out Stream? stream, [NotNullWhen(true)] out string? objectType) + { + (stream, objectType) = TryGetObjectCore(objectId, objectType: null) is { } result + ? (result.Stream, result.ObjectType) + : (null, null); + return stream is not null; + } + + /// + public void Dispose() + { + foreach (var pack in this.packsByName.Values) + { + pack.Dispose(); + } + + this.packsByName.Clear(); + + if (this.multiPackIndex.IsValueCreated) + { + this.multiPackIndex.Value?.Dispose(); + } + + foreach (var alternate in this.alternates) + { + alternate.Dispose(); + } + } + + private (Stream Stream, string ObjectType)? TryGetObjectCore(GitObjectId objectId, string? objectType) + { + // Prefer the multi-pack-index when present. + if (this.multiPackIndex.Value is { } midx && midx.GetOffset(objectId) is { } location) + { + var pack = GetPack(midx.PackNames[location.PackIndex]); + + try + { + return pack.GetObject(location.Offset, objectType); + } + catch (GitObjectStoreException exception) when (exception.ObjectNotFound) + { + // The object exists, but with a different type than requested. + return null; + } + } + + // Fall back to the per-pack .idx files (also covers packs newer than the multi-pack-index). + foreach (var packName in this.packNames.Value) + { + if (GetPack(packName).TryGetObject(objectId, objectType, out var stream, out var actualType)) + { + return (stream, actualType); + } + } + + if (TryGetLooseObject(objectId, objectType) is { } looseObject) + { + return looseObject; + } + + foreach (var alternate in this.alternates) + { + if (alternate.TryGetObjectCore(objectId, objectType) is { } fromAlternate) + { + return fromAlternate; + } + } + + return null; + } + + private (Stream Stream, string ObjectType)? TryGetLooseObject(GitObjectId objectId, string? objectType) + { + var sha = objectId.ToString(); + var path = Path.Combine(ObjectDirectory, sha[..2], sha[2..]); + + if (!FileHelpers.TryOpen(path, out var fileStream)) + { + return null; + } + + var objectStream = new GitObjectStream(fileStream); + + if (objectType is not null && objectStream.ObjectType != objectType) + { + objectStream.Dispose(); + return null; + } + + return (objectStream, objectStream.ObjectType); + } + + private GitPack GetPack(string packName) + { + if (!this.packsByName.TryGetValue(packName, out var pack)) + { + var packDirectory = Path.Combine(ObjectDirectory, "pack"); + pack = new( + (id, type) => TryGetObjectCore(id, type), + Path.Combine(packDirectory, packName + ".idx"), + Path.Combine(packDirectory, packName + ".pack")); + this.packsByName.Add(packName, pack); + } + + return pack; + } + + private string[] LoadPackNames() + { + var packDirectory = Path.Combine(ObjectDirectory, "pack"); + + if (!Directory.Exists(packDirectory)) + { + return []; + } + + return [.. Directory.GetFiles(packDirectory, "*.idx") + .Where(indexPath => File.Exists(Path.ChangeExtension(indexPath, ".pack"))) + .Select(Path.GetFileNameWithoutExtension) + .OfType() + .Order(StringComparer.Ordinal)]; + } + + private GitMultiPackIndexReader? LoadMultiPackIndex() + { + var multiPackIndexPath = Path.Combine(ObjectDirectory, "pack", "multi-pack-index"); + + return FileHelpers.TryOpen(multiPackIndexPath, out var stream) + ? new GitMultiPackIndexReader(stream) + : null; + } + + private void LoadAlternates() + { + var alternatesPath = Path.Combine(ObjectDirectory, "info", "alternates"); + + if (!File.Exists(alternatesPath)) + { + return; + } + + foreach (var line in File.ReadLines(alternatesPath)) + { + var alternatePath = line.Trim(); + + if (alternatePath.Length == 0 || alternatePath.StartsWith('#')) + { + continue; + } + + if (!Path.IsPathRooted(alternatePath)) + { + alternatePath = Path.Combine(ObjectDirectory, alternatePath); + } + + if (Directory.Exists(alternatePath)) + { + // Alternates are followed one level deep only. + this.alternates.Add(new(alternatePath, followAlternates: false)); + } + } + } +} diff --git a/src/GitVersion.Git.Managed/GitObjectStoreException.cs b/src/GitVersion.Git.Managed/GitObjectStoreException.cs new file mode 100644 index 0000000000..ee0ffd5e2d --- /dev/null +++ b/src/GitVersion.Git.Managed/GitObjectStoreException.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git; + +/// +/// The exception thrown by the managed Git object store when the object database +/// is malformed or a requested object cannot be found. +/// +[SuppressMessage("Critical Code Smell", "S3871:Exception types should be \"public\"", Justification = "Every type in this vendored library is internal by design (Phase B.1); the exception never crosses the assembly boundary.")] +internal sealed class GitObjectStoreException : Exception +{ + public GitObjectStoreException() + { + } + + public GitObjectStoreException(string message) : base(message) + { + } + + public GitObjectStoreException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Gets a value indicating whether this exception represents a missing object + /// (as opposed to a malformed object database). + /// + public bool ObjectNotFound { get; init; } +} diff --git a/src/GitVersion.Git.Managed/GitObjectStream.cs b/src/GitVersion.Git.Managed/GitObjectStream.cs new file mode 100644 index 0000000000..21c9030e45 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitObjectStream.cs @@ -0,0 +1,75 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// A which reads data stored as a loose object in the Git object store. +/// The data is stored as a zlib-compressed stream prefixed with the object type and data length. +/// +internal sealed class GitObjectStream : GitZLibStream +{ + /// + /// Initializes a new instance of the class. + /// + /// The from which to read data. + public GitObjectStream(Stream stream) + : base(stream) => ObjectType = ReadObjectTypeAndLength(); + + /// + /// Gets the object type of this Git object, such as commit, tree, blob or tag. + /// + public string ObjectType { get; } + + private string ReadObjectTypeAndLength() + { + Span buffer = stackalloc byte[16]; + var typeLength = 0; + + while (true) + { + var read = ReadByte(); + if (read == -1) + { + throw new EndOfStreamException(); + } + + if (read == ' ') + { + break; + } + + if (typeLength >= buffer.Length) + { + throw new GitObjectStoreException("Invalid loose object header: the object type is too long."); + } + + buffer[typeLength++] = (byte)read; + } + + long length = 0; + + while (true) + { + var read = ReadByte(); + if (read == -1) + { + throw new EndOfStreamException(); + } + + if (read == 0) + { + break; + } + + if (read is < '0' or > '9') + { + throw new GitObjectStoreException("Invalid loose object header: the object length is malformed."); + } + + length = (10 * length) + (read - '0'); + } + + Initialize(length); + return GitObjectTypes.Canonicalize(buffer[..typeLength]); + } +} diff --git a/src/GitVersion.Git.Managed/GitObjectTypes.cs b/src/GitVersion.Git.Managed/GitObjectTypes.cs new file mode 100644 index 0000000000..14cad41590 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitObjectTypes.cs @@ -0,0 +1,42 @@ +namespace GitVersion.Git; + +/// +/// Well-known Git object type names. +/// +internal static class GitObjectTypes +{ + public const string Commit = "commit"; + public const string Tree = "tree"; + public const string Blob = "blob"; + public const string Tag = "tag"; + + /// + /// Returns the canonical (interned) object type name for the given UTF-8 encoded type. + /// + /// The UTF-8 encoded object type name. + /// The canonical object type name. + public static string Canonicalize(ReadOnlySpan type) + { + if (type.SequenceEqual("commit"u8)) + { + return Commit; + } + + if (type.SequenceEqual("tree"u8)) + { + return Tree; + } + + if (type.SequenceEqual("blob"u8)) + { + return Blob; + } + + if (type.SequenceEqual("tag"u8)) + { + return Tag; + } + + throw new GitObjectStoreException($"Unknown git object type '{Encoding.UTF8.GetString(type)}'."); + } +} diff --git a/src/GitVersion.Git.Managed/GitPack.cs b/src/GitVersion.Git.Managed/GitPack.cs new file mode 100644 index 0000000000..027f5ca8fd --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPack.cs @@ -0,0 +1,162 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git; + +/// +/// A delegate which resolves a Git object across the whole object store (used to resolve +/// the base object of ref-delta deltified objects, which may live outside the pack). +/// +/// The Git object id of the object to fetch. +/// The expected object type, or to accept any type. +/// A stream over the object data and its actual object type, or if not found. +internal delegate (Stream Stream, string ObjectType)? ResolveGitObject(GitObjectId objectId, string? objectType); + +/// +/// Supports retrieving objects from a Git pack file. +/// +/// +internal sealed class GitPack : IDisposable +{ + private readonly ResolveGitObject resolveGitObject; + private readonly Lazy packStream; + private readonly GitPackCache cache; + + // Maps GitObjectIds to offsets in the git pack. + private readonly Dictionary offsets = []; + + private readonly Lazy indexReader; + + /// + /// Initializes a new instance of the class. + /// + /// A delegate which fetches objects from the Git object store. + /// The full path to the index (.idx) file. + /// The full path to the pack file. + /// A which is used to cache objects which operate on the pack file. + public GitPack(ResolveGitObject resolveGitObject, string indexPath, string packPath, GitPackCache? cache = null) + { + this.resolveGitObject = resolveGitObject ?? throw new ArgumentNullException(nameof(resolveGitObject)); + this.indexReader = new(() => new GitPackIndexReader(File.OpenRead(indexPath))); + this.packStream = new(() => File.OpenRead(packPath)); + this.cache = cache ?? new GitPackMemoryCache(); + } + + /// + /// Resolves a Git object across the whole object store. Used to resolve the base + /// object of ref-delta deltified objects. + /// + /// The Git object id of the object to fetch. + /// The expected object type, or to accept any type. + /// A stream over the object data and its actual object type, or if not found. + public (Stream Stream, string ObjectType)? ResolveBaseObject(GitObjectId objectId, string? objectType) => + this.resolveGitObject(objectId, objectType); + + /// + /// Attempts to retrieve a Git object from this Git pack. + /// + /// The Git object id of the object to retrieve. + /// The expected object type, or to accept any type. + /// If found, receives a which represents the object. + /// If found, receives the actual object type. + /// if the object was found; otherwise, . + public bool TryGetObject(GitObjectId objectId, string? objectType, [NotNullWhen(true)] out Stream? stream, [NotNullWhen(true)] out string? actualType) + { + var offset = GetOffset(objectId); + + if (offset is null) + { + stream = null; + actualType = null; + return false; + } + + try + { + (stream, actualType) = GetObject(offset.Value, objectType); + return true; + } + catch (GitObjectStoreException exception) when (exception.ObjectNotFound) + { + stream = null; + actualType = null; + return false; + } + } + + /// + /// Gets a Git object at a specific offset. + /// + /// The offset of the Git object, relative to the pack file. + /// The expected object type, or to accept any type. + /// A which represents the object, and the actual object type. + public (Stream Stream, string ObjectType) GetObject(long offset, string? objectType) + { + if (this.cache.TryOpen(offset, out var cachedStream, out var cachedType)) + { + if (objectType is not null && cachedType != objectType) + { + cachedStream.Dispose(); + throw new GitObjectStoreException($"An object of type {objectType} could not be located at offset {offset}.") { ObjectNotFound = true }; + } + + return (cachedStream, cachedType); + } + + var packDataStream = GetPackStream(); + Stream objectStream; + string actualType; + + try + { + (objectStream, actualType) = GitPackReader.GetObject(this, packDataStream, offset, objectType); + } + catch + { + packDataStream.Dispose(); + throw; + } + + return (this.cache.Add(offset, objectStream, actualType), actualType); + } + + /// + public void Dispose() + { + if (this.indexReader.IsValueCreated) + { + this.indexReader.Value.Dispose(); + } + + if (this.packStream.IsValueCreated) + { + this.packStream.Value.Dispose(); + } + + this.cache.Dispose(); + } + + private long? GetOffset(GitObjectId objectId) + { + if (this.offsets.TryGetValue(objectId, out var cachedOffset)) + { + return cachedOffset; + } + + var offset = this.indexReader.Value.GetOffset(objectId); + + if (offset is not null) + { + this.offsets.Add(objectId, offset.Value); + } + + return offset; + } + + private Stream GetPackStream() + { + var file = this.packStream.Value; + return new RandomAccessStream(file.SafeFileHandle, file.Length); + } +} diff --git a/src/GitVersion.Git.Managed/GitPackCache.cs b/src/GitVersion.Git.Managed/GitPackCache.cs new file mode 100644 index 0000000000..d3f85b3b09 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackCache.cs @@ -0,0 +1,46 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git; + +/// +/// 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. +/// +internal abstract class GitPackCache : IDisposable +{ + /// + /// 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 data, if found. + /// The object type of the cached object, if found. + /// if the object was found in cache; otherwise, . + public abstract bool TryOpen(long offset, [NotNullWhen(true)] out Stream? stream, [NotNullWhen(true)] out string? objectType); + + /// + /// Adds a Git object to this cache. + /// + /// The offset of the Git object in the Git pack. + /// A which represents the object to add. + /// The object type of the object to add to the cache. + /// A which represents the cached entry. + public abstract Stream Add(long offset, Stream stream, string objectType); + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of native and managed resources associated by this object. + /// + /// to dispose managed and native resources; to only dispose of native resources. + protected virtual void Dispose(bool disposing) + { + } +} diff --git a/src/GitVersion.Git.Managed/GitPackDeltafiedStream.cs b/src/GitVersion.Git.Managed/GitPackDeltafiedStream.cs new file mode 100644 index 0000000000..ae2eab79a1 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackDeltafiedStream.cs @@ -0,0 +1,159 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Reads data from a deltified object. +/// +/// +internal sealed class GitPackDeltafiedStream : Stream +{ + private readonly long length; + + private readonly Stream baseStream; + private readonly Stream deltaStream; + + private long position; + 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)); + + _ = this.deltaStream.ReadMbsInt(); // base object length + this.length = this.deltaStream.ReadMbsInt(); + } + + /// + 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 NotSupportedException(); + } + + /// + public override int Read(Span buffer) + { + var read = 0; + + while (read < buffer.Length && TryGetInstruction(out var instruction)) + { + var source = instruction.InstructionType == DeltaInstructionType.Copy ? this.baseStream : this.deltaStream; + + var canRead = Math.Min(buffer.Length - read, instruction.Size - this.offset); + var didRead = source.Read(buffer.Slice(read, canRead)); + + if (didRead == 0) + { + throw new EndOfStreamException(); + } + + read += didRead; + this.offset += didRead; + } + + this.position += read; + return read; + } + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + public override void Flush() => throw new NotSupportedException(); + + /// + 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) + { + this.ReadBytes(checked((int)(offset - this.position))); + return this.position; + } + + throw new NotSupportedException("Only forward seeks are supported."); + } + + /// + 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) + { + if (disposing) + { + this.deltaStream.Dispose(); + this.baseStream.Dispose(); + } + + base.Dispose(disposing); + } + + private bool TryGetInstruction(out DeltaInstruction instruction) + { + if (this.current is not null && this.offset < this.current.Value.Size) + { + instruction = this.current.Value; + return true; + } + + this.current = DeltaStreamReader.Read(this.deltaStream); + + if (this.current is null) + { + instruction = default; + return false; + } + + instruction = this.current.Value; + + switch (instruction.InstructionType) + { + case DeltaInstructionType.Copy: + this.baseStream.Seek(instruction.Offset, SeekOrigin.Begin); + this.offset = 0; + break; + + case DeltaInstructionType.Insert: + this.offset = 0; + break; + + default: + throw new GitObjectStoreException($"Invalid delta instruction type '{instruction.InstructionType}'."); + } + + return true; + } +} diff --git a/src/GitVersion.Git.Managed/GitPackIndexReader.cs b/src/GitVersion.Git.Managed/GitPackIndexReader.cs new file mode 100644 index 0000000000..d61973b801 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackIndexReader.cs @@ -0,0 +1,147 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers.Binary; + +namespace GitVersion.Git; + +/// +/// Reads a Git pack index (.idx) file, version 2. +/// +/// +internal sealed class GitPackIndexReader : IDisposable +{ + private static readonly byte[] Header = [0xff, 0x74, 0x4f, 0x63]; + + // The object name table starts at: 4 (header) + 4 (version) + 256 * 4 (fanout table). + private const int NameTableStart = 4 + 4 + (256 * 4); + + private readonly FileStream stream; + + // 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 bool initialized; + + /// + /// Initializes a new instance of the class. + /// + /// A which points to the index file. Ownership is transferred to the reader. + public GitPackIndexReader(FileStream stream) => this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); + + /// + /// Gets the offset of a Git object in the pack 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 pack file; otherwise, . + public long? GetOffset(GitObjectId objectId) + { + const int hashLength = GitObjectId.Sha1Size; + + Initialize(); + + Span objectName = stackalloc byte[hashLength]; + objectId.CopyTo(objectName); + + var objectCount = this.fanoutTable[256]; + + // The fanout table is followed by a table of sorted 20-byte SHA-1 object names. + var low = this.fanoutTable[objectName[0]]; + var high = this.fanoutTable[objectName[0] + 1] - 1; + + Span current = stackalloc byte[hashLength]; + var order = -1; + var i = 0; + + while (low <= high) + { + i = (low + high) / 2; + + this.stream.SafeFileHandle.ReadExactlyAt(NameTableStart + ((long)hashLength * i), current); + order = current.SequenceCompareTo(objectName); + + if (order < 0) + { + low = i + 1; + } + else if (order > 0) + { + high = i - 1; + } + else + { + break; + } + } + + if (order != 0) + { + return null; + } + + // Get the offset value. It's located at: + // 4 (header) + 4 (version) + 256 * 4 (fanout table) + 20 * objectCount (name table) + 4 * objectCount (CRC32) + 4 * i (offset values) + var offsetTableStart = NameTableStart + (((long)hashLength + 4) * objectCount); + Span offsetBuffer = stackalloc byte[8]; + + this.stream.SafeFileHandle.ReadExactlyAt(offsetTableStart + (4L * i), offsetBuffer[..4]); + var offset = BinaryPrimitives.ReadUInt32BigEndian(offsetBuffer[..4]); + + if ((offset & 0x8000_0000) == 0) + { + return offset; + } + + // 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 &= 0x7FFFFFFF; + + this.stream.SafeFileHandle.ReadExactlyAt(offsetTableStart + (4L * objectCount) + (8L * offset), offsetBuffer); + return BinaryPrimitives.ReadInt64BigEndian(offsetBuffer); + } + + /// + public void Dispose() => this.stream.Dispose(); + + private void Initialize() + { + if (this.initialized) + { + return; + } + + const int fanoutTableLength = 256; + Span value = stackalloc byte[4 + 4 + (4 * fanoutTableLength)]; + this.stream.SafeFileHandle.ReadExactlyAt(0, value); + + var header = value[..4]; + var version = BinaryPrimitives.ReadInt32BigEndian(value.Slice(4, 4)); + + if (!header.SequenceEqual(Header)) + { + throw new GitObjectStoreException("The pack index file has an invalid header."); + } + + if (version != 2) + { + throw new GitObjectStoreException($"Pack index version {version} is not supported; only version 2 is supported."); + } + + for (var i = 1; i <= fanoutTableLength; i++) + { + // The on-disk fanout entries are unsigned 32-bit integers. + var entry = BinaryPrimitives.ReadUInt32BigEndian(value.Slice(4 + (4 * i), 4)); + + if (entry > int.MaxValue) + { + throw new GitObjectStoreException($"The pack index file has a fanout entry of {entry} objects, which exceeds the supported maximum of {int.MaxValue}."); + } + + this.fanoutTable[i] = (int)entry; + } + + this.initialized = true; + } +} diff --git a/src/GitVersion.Git.Managed/GitPackMemoryCache.cs b/src/GitVersion.Git.Managed/GitPackMemoryCache.cs new file mode 100644 index 0000000000..a8d5d0b04b --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackMemoryCache.cs @@ -0,0 +1,115 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace GitVersion.Git; + +/// +/// +/// When a is added to the , it is wrapped +/// in a . This stream allows for just-in-time, random, +/// read-only access to the underlying data (which may be deltified and/or compressed). +/// +/// +/// Whenever data is read from a , the call is forwarded to the +/// underlying and cached in a . If the same data is read +/// twice, it is read from the , rather than the underlying . +/// +/// +/// The cache is bounded: when the decompressed size of the retained entries exceeds the budget, +/// the least recently used entries are evicted (and disposed once no view is reading them), +/// so full-history walks over large repositories do not retain every object in memory. +/// +/// +internal sealed class GitPackMemoryCache : GitPackCache +{ + // libgit2 caps its object cache at 256 MB by default; the same budget keeps the + // hot delta-base entries while releasing objects no walk will read again. + private const long MaxTotalSize = 256 * 1024 * 1024; + + private readonly object syncRoot = new(); + private readonly Dictionary> cache = []; + private readonly LinkedList recency = new(); + private long totalSize; + + private sealed record CacheEntry(long Offset, GitPackMemoryCacheStream Stream, string ObjectType); + + /// + public override Stream Add(long offset, Stream stream, string objectType) + { + lock (this.syncRoot) + { + if (this.cache.TryGetValue(offset, out var existing)) + { + // Someone cached this offset between the caller's TryOpen and this Add: + // serve the existing entry (refreshing its recency) and discard the source + // so hot objects are decompressed and held in memory only once. + stream.Dispose(); + this.recency.Remove(existing); + this.recency.AddFirst(existing); + return new GitPackMemoryCacheViewStream(existing.Value.Stream); + } + + var cacheStream = new GitPackMemoryCacheStream(stream); + var node = this.recency.AddFirst(new CacheEntry(offset, cacheStream, objectType)); + this.cache.Add(offset, node); + this.totalSize += cacheStream.Length; + EvictWhileOverBudget(); + return new GitPackMemoryCacheViewStream(cacheStream); + } + } + + /// + public override bool TryOpen(long offset, [NotNullWhen(true)] out Stream? stream, [NotNullWhen(true)] out string? objectType) + { + lock (this.syncRoot) + { + if (this.cache.TryGetValue(offset, out var node)) + { + this.recency.Remove(node); + this.recency.AddFirst(node); + stream = new GitPackMemoryCacheViewStream(node.Value.Stream); + objectType = node.Value.ObjectType; + return true; + } + } + + stream = null; + objectType = null; + return false; + } + + private void EvictWhileOverBudget() + { + // Never evict the most recent entry: a single object larger than the whole + // budget must still be readable through the entry just added. + while (this.totalSize > MaxTotalSize && this.recency.Count > 1 && this.recency.Last is { } tail) + { + this.recency.RemoveLast(); + this.cache.Remove(tail.Value.Offset); + this.totalSize -= tail.Value.Stream.Length; + tail.Value.Stream.Release(); + } + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + lock (this.syncRoot) + { + foreach (var entry in this.recency) + { + entry.Stream.Release(); + } + + this.recency.Clear(); + this.cache.Clear(); + this.totalSize = 0; + } + } + + base.Dispose(disposing); + } +} diff --git a/src/GitVersion.Git.Managed/GitPackMemoryCacheStream.cs b/src/GitVersion.Git.Managed/GitPackMemoryCacheStream.cs new file mode 100644 index 0000000000..54cde02ea5 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackMemoryCacheStream.cs @@ -0,0 +1,136 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// A stream which caches the data read from an underlying (non-seekable) stream in memory, +/// providing random read-only access to that data. +/// +internal sealed class GitPackMemoryCacheStream : Stream +{ + private readonly Stream stream; + private readonly MemoryStream cacheStream = new(); + private readonly long length; + private int referenceCount = 1; + private bool released; + + public GitPackMemoryCacheStream(Stream stream) + { + this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); + this.length = this.stream.Length; + } + + /// + /// Registers an additional consumer (a ) of + /// this stream. The underlying data is only disposed once the cache has evicted the + /// entry and the last consumer has released it. + /// + public void AddReference() => Interlocked.Increment(ref this.referenceCount); + + /// + /// Releases one consumer's reference, disposing the underlying streams when none remain. + /// + public void Release() + { + if (Interlocked.Decrement(ref this.referenceCount) == 0) + { + this.stream.Dispose(); + this.cacheStream.Dispose(); + } + } + + /// + /// Gets the object on which instances synchronize + /// their access to this shared stream. + /// + public object SyncRoot { get; } = new(); + + /// + 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(); + + /// + public override int Read(Span buffer) + { + 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); + var actualRead = this.stream.Read(buffer[..toRead]); + this.cacheStream.Seek(0, SeekOrigin.End); + this.cacheStream.Write(buffer[..actualRead]); + this.cacheStream.Seek(currentPosition, SeekOrigin.Begin); + DisposeStreamIfRead(); + } + + return this.cacheStream.Read(buffer); + } + + /// + public override int Read(byte[] buffer, int offset, int count) => 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) + { + this.cacheStream.Seek(0, SeekOrigin.End); + var toRead = (int)(offset - this.cacheStream.Length); + this.stream.ReadBytes(toRead, this.cacheStream); + DisposeStreamIfRead(); + return this.cacheStream.Position; + } + + 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(); + + /// + protected override void Dispose(bool disposing) + { + if (disposing && !this.released) + { + this.released = true; + Release(); + } + + base.Dispose(disposing); + } + + private void DisposeStreamIfRead() + { + if (this.cacheStream.Length == this.stream.Length) + { + this.stream.Dispose(); + } + } +} diff --git a/src/GitVersion.Git.Managed/GitPackMemoryCacheViewStream.cs b/src/GitVersion.Git.Managed/GitPackMemoryCacheViewStream.cs new file mode 100644 index 0000000000..abd7b1a987 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackMemoryCacheViewStream.cs @@ -0,0 +1,95 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// A read-only view over a shared which maintains +/// its own position, so multiple readers can independently consume the same cached object. +/// +internal sealed class GitPackMemoryCacheViewStream : Stream +{ + private readonly GitPackMemoryCacheStream baseStream; + + private long position; + private bool disposed; + + public GitPackMemoryCacheViewStream(GitPackMemoryCacheStream baseStream) + { + this.baseStream = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + this.baseStream.AddReference(); + } + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + public override long Length => this.baseStream.Length; + + /// + public override long Position + { + get => this.position; + set => throw new NotSupportedException(); + } + + /// + public override void Flush() => throw new NotSupportedException(); + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span buffer) + { + int read; + + lock (this.baseStream.SyncRoot) + { + if (this.baseStream.Position != this.position) + { + this.baseStream.Seek(this.position, SeekOrigin.Begin); + } + + read = this.baseStream.Read(buffer); + } + + this.position += read; + return read; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + if (origin != SeekOrigin.Begin) + { + throw new NotSupportedException(); + } + + this.position = Math.Min(offset, Length); + 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(); + + /// + protected override void Dispose(bool disposing) + { + if (disposing && !this.disposed) + { + this.disposed = true; + this.baseStream.Release(); + } + + base.Dispose(disposing); + } +} diff --git a/src/GitVersion.Git.Managed/GitPackObjectType.cs b/src/GitVersion.Git.Managed/GitPackObjectType.cs new file mode 100644 index 0000000000..54fc369b69 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackObjectType.cs @@ -0,0 +1,31 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Enumerates the object types which can be stored in a Git pack file. +/// +/// +internal enum GitPackObjectType +{ + /// Invalid. + Invalid = 0, + + /// A commit. + Commit = 1, + + /// A tree. + Tree = 2, + + /// A blob. + Blob = 3, + + /// A tag. + Tag = 4, + + /// A deltified object whose base object is referenced by a relative offset in the same pack. + OfsDelta = 6, + + /// A deltified object whose base object is referenced by object id. + RefDelta = 7 +} diff --git a/src/GitVersion.Git.Managed/GitPackReader.cs b/src/GitVersion.Git.Managed/GitPackReader.cs new file mode 100644 index 0000000000..fb57c879d1 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitPackReader.cs @@ -0,0 +1,133 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +internal static class GitPackReader +{ + /// + /// Reads a Git object which is stored at a given offset in a Git pack file. + /// + /// The pack from which to read the object. + /// A (seekable) stream over the pack file. Ownership is transferred to the returned stream. + /// The offset of the object in the pack file. + /// The expected object type, or to accept any type. + /// A stream over the object data, and the actual object type. + public static (Stream Stream, string ObjectType) GetObject(GitPack pack, Stream stream, long offset, string? objectType) + { + ArgumentNullException.ThrowIfNull(pack); + ArgumentNullException.ThrowIfNull(stream); + + try + { + stream.Seek(offset, SeekOrigin.Begin); + + var (type, decompressedSize) = ReadObjectHeader(stream); + + switch (type) + { + case GitPackObjectType.OfsDelta: + { + var baseObjectRelativeOffset = ReadVariableLengthInteger(stream); + var baseObjectOffset = offset - baseObjectRelativeOffset; + + // The base of an ofs-delta always precedes the delta in the pack; a zero + // relative offset would recurse into this same object until the stack + // overflows, and a negative target would seek outside the pack. + if (baseObjectOffset <= 0 || baseObjectOffset >= offset) + { + throw new GitObjectStoreException($"The deltified object at offset {offset} has an invalid base object offset {baseObjectOffset}."); + } + + var deltaStream = new GitZLibStream(stream, decompressedSize); + var (baseStream, baseType) = pack.GetObject(baseObjectOffset, objectType); + + return (new GitPackDeltafiedStream(baseStream, deltaStream), baseType); + } + + case GitPackObjectType.RefDelta: + { + Span baseObjectId = stackalloc byte[GitObjectId.Sha1Size]; + stream.ReadExactly(baseObjectId); + + var baseObject = pack.ResolveBaseObject(GitObjectId.Parse(baseObjectId), objectType) + ?? throw new GitObjectStoreException($"The base object of the deltified object at offset {offset} could not be found.") { ObjectNotFound = true }; + + var seekableBaseObject = new GitPackMemoryCacheStream(baseObject.Stream); + var deltaStream = new GitZLibStream(stream, decompressedSize); + + return (new GitPackDeltafiedStream(seekableBaseObject, deltaStream), baseObject.ObjectType); + } + + default: + { + var actualType = type switch + { + GitPackObjectType.Commit => GitObjectTypes.Commit, + GitPackObjectType.Tree => GitObjectTypes.Tree, + GitPackObjectType.Blob => GitObjectTypes.Blob, + GitPackObjectType.Tag => GitObjectTypes.Tag, + _ => throw new GitObjectStoreException($"The object at offset {offset} has an unsupported pack object type '{type}'.") + }; + + if (objectType is not null && actualType != objectType) + { + throw new GitObjectStoreException($"An object of type {objectType} could not be located at offset {offset}.") { ObjectNotFound = true }; + } + + return (new GitZLibStream(stream, decompressedSize), actualType); + } + } + } + catch (EndOfStreamException eof) + { + throw new GitObjectStoreException($"An object could not be located at offset {offset}.", eof) { ObjectNotFound = true }; + } + } + + private static (GitPackObjectType ObjectType, long Length) ReadObjectHeader(Stream stream) + { + Span value = stackalloc byte[1]; + stream.ReadExactly(value); + + var type = (GitPackObjectType)((value[0] & 0b0111_0000) >> 4); + long length = value[0] & 0b_1111; + + if ((value[0] & 0b1000_0000) == 0) + { + return (type, length); + } + + var shift = 4; + + do + { + stream.ReadExactly(value); + length |= (value[0] & 0b0111_1111L) << shift; + shift += 7; + } + while ((value[0] & 0b1000_0000) != 0); + + return (type, length); + } + + private static long ReadVariableLengthInteger(Stream stream) + { + long offset = -1; + int b; + + do + { + offset++; + b = stream.ReadByte(); + if (b == -1) + { + throw new EndOfStreamException(); + } + + offset = (offset << 7) + (b & 127); + } + while ((b & 128) != 0); + + return offset; + } +} diff --git a/src/GitVersion.Git.Managed/GitSignature.cs b/src/GitVersion.Git.Managed/GitSignature.cs new file mode 100644 index 0000000000..35e477fb13 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitSignature.cs @@ -0,0 +1,107 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers.Text; + +namespace GitVersion.Git; + +/// +/// Represents the signature of a Git committer, author or tagger. +/// +/// The name of the person. +/// The e-mail address of the person. +/// The date and time of the action, including the recorded UTC offset. +internal readonly record struct GitSignature(string Name, string Email, DateTimeOffset When) +{ + /// + /// Parses a signature line value in the format Name <email> timestamp offset, + /// for example Jane Doe <jane@example.com> 1580753429 +0100. + /// + /// The signature value (excluding the author/committer/tagger prefix). + /// The value of the containing object's encoding header, if present. + /// The parsed . + public static GitSignature Parse(ReadOnlySpan value, string? encodingName = null) + { + var (emailStart, emailEnd) = FindEmailDelimiters(value); + + var name = value[..Math.Max(emailStart - 1, 0)]; + var email = value[(emailStart + 1)..emailEnd]; + var when = ParseTime(value[Math.Min(emailEnd + 2, value.Length)..]); + + return new(GitTextDecoder.Decode(name, encodingName), GitTextDecoder.Decode(email, encodingName), when); + } + + /// + /// Parses only the timestamp of a signature line value, without decoding the name + /// or the e-mail address into strings. + /// + /// The signature value (excluding the author/committer/tagger prefix). + /// The date and time of the action, including the recorded UTC offset. + public static DateTimeOffset ParseWhen(ReadOnlySpan value) + { + var (_, emailEnd) = FindEmailDelimiters(value); + return ParseTime(value[Math.Min(emailEnd + 2, value.Length)..]); + } + + private static (int EmailStart, int EmailEnd) FindEmailDelimiters(ReadOnlySpan value) + { + var emailStart = value.IndexOf((byte)'<'); + var emailEnd = value.IndexOf((byte)'>'); + + if (emailStart < 0 || emailEnd < emailStart) + { + throw new GitObjectStoreException("A signature line is malformed: no e-mail address found."); + } + + return (emailStart, emailEnd); + } + + private static DateTimeOffset ParseTime(ReadOnlySpan time) + { + var offsetStart = time.IndexOf((byte)' '); + if (offsetStart < 0) + { + offsetStart = time.Length; + } + + if (!Utf8Parser.TryParse(time[..offsetStart], out long seconds, out _)) + { + throw new GitObjectStoreException("A signature line is malformed: no timestamp found."); + } + + var when = DateTimeOffset.FromUnixTimeSeconds(seconds); + + if (TryParseOffset(time[Math.Min(offsetStart + 1, time.Length)..], out var offset)) + { + when = when.ToOffset(offset); + } + + return when; + } + + private static bool TryParseOffset(ReadOnlySpan value, out TimeSpan offset) + { + offset = TimeSpan.Zero; + + if (value.Length < 5 || (value[0] != '+' && value[0] != '-')) + { + return false; + } + + var hours = ((value[1] - '0') * 10) + (value[2] - '0'); + var minutes = ((value[3] - '0') * 10) + (value[4] - '0'); + + if (hours is < 0 or > 14 || minutes is < 0 or > 59) + { + return false; + } + + offset = new TimeSpan(hours, minutes, 0); + + if (value[0] == '-') + { + offset = offset.Negate(); + } + + return true; + } +} diff --git a/src/GitVersion.Git.Managed/GitTag.cs b/src/GitVersion.Git.Managed/GitTag.cs new file mode 100644 index 0000000000..ca3c152fb6 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitTag.cs @@ -0,0 +1,42 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Represents a Git annotated tag, as stored in the Git object database. +/// +internal sealed class GitTag +{ + /// + /// Gets the which uniquely identifies this annotated tag. + /// + public required GitObjectId Sha { get; init; } + + /// + /// Gets the of the object this tag points at. + /// + public required GitObjectId Target { get; init; } + + /// + /// Gets the type of the object this tag points at, e.g. commit or, for nested tags, tag. + /// + public required string TargetType { get; init; } + + /// + /// Gets the name of this tag. + /// + public required string Name { get; init; } + + /// + /// Gets the tagger of this tag, or when the tag has no tagger header. + /// + public GitSignature? Tagger { get; init; } + + /// + /// Gets the tag message. + /// + public required string Message { get; init; } + + /// + public override string ToString() => $"Git Tag: {Name} with id {Sha}"; +} diff --git a/src/GitVersion.Git.Managed/GitTagReader.cs b/src/GitVersion.Git.Managed/GitTagReader.cs new file mode 100644 index 0000000000..1f35646f93 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitTagReader.cs @@ -0,0 +1,108 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers; + +namespace GitVersion.Git; + +/// +/// Reads a (annotated tag) object. +/// +internal static class GitTagReader +{ + /// + /// Reads a object from a . + /// + /// A which contains the tag in its text representation. + /// The of the tag. + /// The . + public static GitTag Read(Stream stream, GitObjectId sha) + { + ArgumentNullException.ThrowIfNull(stream); + + var buffer = ArrayPool.Shared.Rent(checked((int)stream.Length)); + + try + { + var span = buffer.AsSpan(0, (int)stream.Length); + stream.ReadExactly(span); + + return Read(span, sha); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Reads a object from a . + /// + /// A which contains the tag in its text representation. + /// The of the tag. + /// The . + public static GitTag Read(ReadOnlySpan tag, GitObjectId sha) + { + GitObjectId? target = null; + string? targetType = null; + string? name = null; + GitSignature? tagger = null; + + var buffer = tag; + + while (!buffer.IsEmpty) + { + var lineEnd = buffer.IndexOf((byte)'\n'); + if (lineEnd < 0) + { + lineEnd = buffer.Length; + } + + var line = buffer[..lineEnd]; + buffer = buffer[Math.Min(lineEnd + 1, buffer.Length)..]; + + if (line.IsEmpty) + { + // An empty line separates the headers from the tag message. + break; + } + + if (line[0] == ' ') + { + // A continuation of the previous (multi-line) header; skip. + continue; + } + + if (line.StartsWith("object "u8)) + { + target = GitObjectId.ParseHex(line["object "u8.Length..]); + } + else if (line.StartsWith("type "u8)) + { + targetType = GitObjectTypes.Canonicalize(line["type "u8.Length..]); + } + else if (line.StartsWith("tag "u8)) + { + name = GitTextDecoder.Decode(line["tag "u8.Length..]); + } + else if (line.StartsWith("tagger "u8)) + { + tagger = GitSignature.Parse(line["tagger "u8.Length..]); + } + } + + if (target is null || targetType is null || name is null) + { + throw new GitObjectStoreException($"The tag {sha} is malformed: an object, type or tag header is missing."); + } + + return new() + { + Sha = sha, + Target = target.Value, + TargetType = targetType, + Name = name, + Tagger = tagger, + Message = GitTextDecoder.Decode(buffer) + }; + } +} diff --git a/src/GitVersion.Git.Managed/GitTextDecoder.cs b/src/GitVersion.Git.Managed/GitTextDecoder.cs new file mode 100644 index 0000000000..ee61767bdf --- /dev/null +++ b/src/GitVersion.Git.Managed/GitTextDecoder.cs @@ -0,0 +1,52 @@ +namespace GitVersion.Git; + +/// +/// Decodes text stored in Git objects, matching git's behavior: content is assumed to be +/// UTF-8 (or the encoding named by the commit's encoding header), falling back to +/// Latin-1 when the bytes are not valid UTF-8. +/// +internal static class GitTextDecoder +{ + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// + /// Decodes the given bytes as UTF-8, falling back to Latin-1 when the bytes are not valid UTF-8. + /// + /// The bytes to decode. + /// The decoded string. + public static string Decode(ReadOnlySpan bytes) + { + try + { + return StrictUtf8.GetString(bytes); + } + catch (DecoderFallbackException) + { + return Encoding.Latin1.GetString(bytes); + } + } + + /// + /// Decodes the given bytes using the named encoding when available, otherwise + /// falling back to . + /// + /// The bytes to decode. + /// The value of the object's encoding header, if present. + /// The decoded string. + public static string Decode(ReadOnlySpan bytes, string? encodingName) + { + if (encodingName is not null && !encodingName.Equals("UTF-8", StringComparison.OrdinalIgnoreCase)) + { + try + { + return Encoding.GetEncoding(encodingName).GetString(bytes); + } + catch (ArgumentException) + { + // Unknown or unsupported encoding: fall back to git's default behavior. + } + } + + return Decode(bytes); + } +} diff --git a/src/GitVersion.Git.Managed/GitTree.cs b/src/GitVersion.Git.Managed/GitTree.cs new file mode 100644 index 0000000000..8445c16792 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitTree.cs @@ -0,0 +1,72 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +namespace GitVersion.Git; + +/// +/// Represents a Git tree, as stored in the Git object database. +/// +internal sealed class GitTree +{ + /// + /// Gets the of this tree. + /// + public required GitObjectId Sha { get; init; } + + /// + /// Gets the entries in this tree, in the order in which they are stored. + /// + public required IReadOnlyList Entries { get; init; } + + /// + /// Gets the entry with the given name, or if no such entry exists. + /// + /// The name of the entry to find. + /// The matching entry, if found. + public GitTreeEntry? FindEntry(string name) => Entries.FirstOrDefault(entry => entry.Name == name); + + /// + public override string ToString() => $"Git tree: {Sha}"; +} + +/// +/// Represents an individual entry in a Git tree. +/// +/// The name of the entry. +/// The raw name bytes of the entry, exactly as stored in the tree object. +/// The file mode of the entry, as stored in the tree object (octal, without leading zeros), e.g. 100644 or 40000. +/// The Git object id of the blob or tree of the entry. +internal sealed class GitTreeEntry(string name, ReadOnlyMemory nameBytes, string mode, GitObjectId sha) +{ + private const string TreeMode = "40000"; + + /// + /// Gets the name of the entry. + /// + public string Name { get; } = name; + + /// + /// Gets the raw name bytes of the entry, exactly as stored in the tree object. + /// Git sorts and aligns tree entries by these bytes; is a decoded + /// form (UTF-8 with a Latin-1 fallback) which does not always round-trip to them. + /// + public ReadOnlyMemory NameBytes { get; } = nameBytes; + + /// + /// Gets the file mode of the entry, as stored in the tree object (octal, without leading zeros), + /// e.g. 100644 for a regular file or 40000 for a directory. + /// + public string Mode { get; } = mode; + + /// + /// Gets the Git object id of the blob or tree of the entry. + /// + public GitObjectId Sha { get; } = sha; + + /// + /// Gets a value indicating whether this entry points to a tree (directory). + /// + public bool IsTree => Mode == TreeMode; + + /// + public override string ToString() => Name; +} diff --git a/src/GitVersion.Git.Managed/GitTreeReader.cs b/src/GitVersion.Git.Managed/GitTreeReader.cs new file mode 100644 index 0000000000..48f54bab56 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitTreeReader.cs @@ -0,0 +1,86 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers; + +namespace GitVersion.Git; + +/// +/// Reads a object. +/// +internal static class GitTreeReader +{ + /// + /// Reads a object from a . + /// + /// A which contains the tree in its binary representation. + /// The of the tree. + /// The . + public static GitTree Read(Stream stream, GitObjectId objectId) + { + ArgumentNullException.ThrowIfNull(stream); + + var buffer = ArrayPool.Shared.Rent(checked((int)stream.Length)); + + try + { + var contents = buffer.AsSpan(0, (int)stream.Length); + stream.ReadExactly(contents); + + return Read(contents, objectId); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Reads a object from a . + /// + /// A which contains the tree in its binary representation. + /// The of the tree. + /// The . + public static GitTree Read(ReadOnlySpan tree, GitObjectId objectId) + { + List entries = []; + + var contents = tree; + + while (!contents.IsEmpty) + { + // Format: [mode] [file/folder name]\0[object id of the referenced blob or tree] + var (name, nameBytes, mode, sha, entryLength) = ReadEntry(contents); + entries.Add(new(name, nameBytes, mode, sha)); + contents = contents[entryLength..]; + } + + return new() + { + Sha = objectId, + Entries = entries + }; + } + + internal static (string Name, byte[] NameBytes, string Mode, GitObjectId Sha, int EntryLength) ReadEntry(ReadOnlySpan contents) + { + const int hashLength = GitObjectId.Sha1Size; + + var modeEnd = contents.IndexOf((byte)' '); + var nameEnd = contents.IndexOf((byte)0); + + if (modeEnd < 0 || nameEnd < modeEnd || contents.Length < nameEnd + 1 + hashLength) + { + throw new GitObjectStoreException("The tree object is malformed."); + } + + var mode = Encoding.UTF8.GetString(contents[..modeEnd]); + // Keep a stable copy of the raw name bytes: the source span may be backed by a + // pooled buffer, and the decoded name (UTF-8 with a Latin-1 fallback) does not + // always round-trip back to the on-disk bytes git sorts entries by. + var nameBytes = contents[(modeEnd + 1)..nameEnd].ToArray(); + var name = GitTextDecoder.Decode(nameBytes); + var sha = GitObjectId.Parse(contents.Slice(nameEnd + 1, hashLength)); + + return (name, nameBytes, mode, sha, nameEnd + 1 + hashLength); + } +} diff --git a/src/GitVersion.Git.Managed/GitTreeStreamingReader.cs b/src/GitVersion.Git.Managed/GitTreeStreamingReader.cs new file mode 100644 index 0000000000..b353ae03c2 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitTreeStreamingReader.cs @@ -0,0 +1,61 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers; + +namespace GitVersion.Git; + +/// +/// Finds entries in Git tree objects by scanning the raw tree bytes directly, +/// without parsing every entry into a model. +/// +internal static class GitTreeStreamingReader +{ + /// + /// Finds a specific node in a git tree. + /// + /// A which represents the git tree. + /// The name of the node to find, in its UTF-8 representation. + /// + /// The of the requested node, or if not found. + /// + public static GitObjectId FindNode(Stream stream, ReadOnlySpan name) + { + ArgumentNullException.ThrowIfNull(stream); + + const int hashLength = GitObjectId.Sha1Size; + + var buffer = ArrayPool.Shared.Rent(checked((int)stream.Length)); + + try + { + var contents = buffer.AsSpan(0, (int)stream.Length); + stream.ReadExactly(contents); + + while (!contents.IsEmpty) + { + var modeEnd = contents.IndexOf((byte)' '); + var nameEnd = contents.IndexOf((byte)0); + + if (modeEnd < 0 || nameEnd < modeEnd || contents.Length < nameEnd + 1 + hashLength) + { + throw new GitObjectStoreException("The tree object is malformed."); + } + + var currentName = contents[(modeEnd + 1)..nameEnd]; + + if (currentName.SequenceEqual(name)) + { + return GitObjectId.Parse(contents.Slice(nameEnd + 1, hashLength)); + } + + contents = contents[(nameEnd + 1 + hashLength)..]; + } + + return GitObjectId.Empty; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/src/GitVersion.Git.Managed/GitVersion.Git.Managed.csproj b/src/GitVersion.Git.Managed/GitVersion.Git.Managed.csproj new file mode 100644 index 0000000000..7ec6e9032d --- /dev/null +++ b/src/GitVersion.Git.Managed/GitVersion.Git.Managed.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/GitVersion.Git.Managed/GitVersionManagedGitModule.cs b/src/GitVersion.Git.Managed/GitVersionManagedGitModule.cs new file mode 100644 index 0000000000..044e309068 --- /dev/null +++ b/src/GitVersion.Git.Managed/GitVersionManagedGitModule.cs @@ -0,0 +1,20 @@ +using GitVersion.Git; + +namespace GitVersion; + +/// +/// Registers the managed Git backend: all repository reads are served by the managed +/// object/reference readers, while mutating and network operations go through the git CLI. +/// +public class GitVersionManagedGitModule : IGitVersionModule +{ + /// Registers the services provided by this module into . + public void RegisterTypes(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => (IMutatingGitRepository)sp.GetRequiredService()); + services.AddSingleton(); + } +} diff --git a/src/GitVersion.Git.Managed/GitZLibStream.cs b/src/GitVersion.Git.Managed/GitZLibStream.cs new file mode 100644 index 0000000000..d15a9c9eec --- /dev/null +++ b/src/GitVersion.Git.Managed/GitZLibStream.cs @@ -0,0 +1,146 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.IO.Compression; + +namespace GitVersion.Git; + +/// +/// A which reads zlib-compressed data. +/// +/// +/// +/// This stream parses but ignores the two-byte zlib header at the start of the compressed +/// stream. It keeps track of the current position and, if provided, the length, and supports +/// forward-only seeking. +/// +/// +/// This class wraps a rather than inheriting from it, because +/// detects whether Read(Span{byte}) is being overridden +/// and behaves differently when it is. +/// +/// +internal class GitZLibStream : Stream +{ + private readonly DeflateStream stream; + private long length; + private long position; + + /// + /// Initializes a new instance of the class. + /// + /// The from which to read data. + /// The size of the uncompressed data. + public GitZLibStream(Stream stream, long length = -1) + { + this.stream = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: false); + this.length = length; + + Span zlibHeader = stackalloc byte[2]; + stream.ReadExactly(zlibHeader); + + if (zlibHeader[0] != 0x78 || (zlibHeader[1] != 0x01 && zlibHeader[1] != 0x9C && zlibHeader[1] != 0x5E && zlibHeader[1] != 0xDA)) + { + throw new GitObjectStoreException($"Invalid zlib header {zlibHeader[0]:X2} {zlibHeader[1]:X2}"); + } + } + + /// + 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[] buffer, int offset, int count) + { + var read = this.stream.Read(buffer, offset, count); + this.position += read; + return read; + } + + /// + public override int Read(Span buffer) + { + var read = this.stream.Read(buffer); + this.position += read; + return read; + } + + /// + public override int ReadByte() + { + var 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) + { + this.ReadBytes(checked((int)(offset - this.position))); + return this.position; + } + + throw new NotSupportedException("Only forward seeks are supported."); + } + + /// + 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) + { + if (disposing) + { + this.stream.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Initializes the length and position properties. + /// + /// The length of the uncompressed data. + protected void Initialize(long length) + { + this.position = 0; + this.length = length; + } +} diff --git a/src/GitVersion.Git.Managed/History/GitRevisionSortStrategies.cs b/src/GitVersion.Git.Managed/History/GitRevisionSortStrategies.cs new file mode 100644 index 0000000000..67068d0f50 --- /dev/null +++ b/src/GitVersion.Git.Managed/History/GitRevisionSortStrategies.cs @@ -0,0 +1,30 @@ +namespace GitVersion.Git; + +/// +/// The sort orders supported by , mirroring the strategies +/// GitVersion uses when querying commit history. +/// +[Flags] +internal enum GitRevisionSortStrategies +{ + /// + /// Git's default ordering: reverse chronological by committer date. + /// + None = 0, + + /// + /// Topological ordering: a commit is only emitted after all of the emitted + /// commits which list it as a parent. + /// + Topological = 1, + + /// + /// Reverse chronological ordering by committer date. + /// + Time = 2, + + /// + /// Reverses the selected ordering. + /// + Reverse = 4 +} diff --git a/src/GitVersion.Git.Managed/History/GitRevisionWalkOptions.cs b/src/GitVersion.Git.Managed/History/GitRevisionWalkOptions.cs new file mode 100644 index 0000000000..2663870a5d --- /dev/null +++ b/src/GitVersion.Git.Managed/History/GitRevisionWalkOptions.cs @@ -0,0 +1,28 @@ +namespace GitVersion.Git; + +/// +/// Describes a revision walk: which commits to start from, which histories to exclude, +/// the sort order, and whether to follow only first parents. +/// +internal sealed class GitRevisionWalkOptions +{ + /// + /// Gets the commits whose histories are included in the walk. + /// + public IList Include { get; } = []; + + /// + /// Gets the commits whose histories (including the commits themselves) are excluded from the walk. + /// + public IList Exclude { get; } = []; + + /// + /// Gets the sort order of the walk. + /// + public GitRevisionSortStrategies Sort { get; init; } = GitRevisionSortStrategies.None; + + /// + /// Gets a value indicating whether only the first parent of each commit is followed. + /// + public bool FirstParentOnly { get; init; } +} diff --git a/src/GitVersion.Git.Managed/History/GitRevisionWalker.cs b/src/GitVersion.Git.Managed/History/GitRevisionWalker.cs new file mode 100644 index 0000000000..26aef0abb4 --- /dev/null +++ b/src/GitVersion.Git.Managed/History/GitRevisionWalker.cs @@ -0,0 +1,831 @@ +using System.Collections.Concurrent; + +namespace GitVersion.Git; + +/// +/// Walks commit history in the orders GitVersion depends on (chronological, topological, +/// reversed, first-parent-only, with include/exclude reachability sets) and computes merge +/// bases. The walk replicates libgit2's revision-walk behavior — including its date-directed +/// limiting pass and its tie-breaking on equal committer timestamps — because GitVersion's +/// version output depends on the exact commit ordering. Parity is validated against libgit2 +/// by the test suite. +/// +/// +/// Shallow-clone boundaries are honored the way git and libgit2 honor the .git/shallow +/// grafts: commits in are treated as parentless, even when +/// their parent objects happen to be reachable through the object store. A parent that fails +/// to load and is not explained by a shallow boundary indicates a corrupt or incomplete +/// repository and fails the walk with a , matching +/// libgit2, instead of silently truncating history. +/// +internal sealed class GitRevisionWalker(GitObjectStore objectStore, IReadOnlySet? shallowCommits = null) +{ + // The number of uninteresting commits to look at after running out of interesting ones, + // matching git's slop heuristic for clock-skewed histories. + private const int Slop = 5; + + private readonly GitObjectStore objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); + private readonly IReadOnlySet shallowCommits = shallowCommits ?? new HashSet(); + private readonly ConcurrentDictionary commitCache = new(); + + /// + /// Walks the commits described by and returns them in order. + /// + /// The walk description. + /// The commits, in the requested order. + public IReadOnlyList Walk(GitRevisionWalkOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var walk = new WalkState(this, options); + var result = walk.Run(); + + if (options.Sort.HasFlag(GitRevisionSortStrategies.Reverse)) + { + result.Reverse(); + } + + return [.. result.Select(node => node.Commit)]; + } + + /// + /// Finds the best common ancestor of two commits, the way git merge-base does: + /// paint-down-to-common followed by elimination of redundant candidates. + /// + /// The first commit. + /// The second commit. + /// The merge base, or when the histories are unrelated. + public GitObjectId? FindMergeBase(GitObjectId first, GitObjectId second) + { + if (first == second) + { + return TryLoad(first) is null ? null : first; + } + + var candidates = PaintDownToCommon(first, second); + + return candidates.Count switch + { + 0 => null, + 1 => candidates[0], + _ => RemoveRedundantCandidates(candidates) + }; + } + + private HashSet CollectReachable(IEnumerable roots, bool firstParentOnly) + { + var reachable = new HashSet(); + var stack = new Stack(); + + foreach (var root in roots) + { + stack.Push(root); + } + + while (stack.Count > 0) + { + var id = stack.Pop(); + + if (!reachable.Add(id)) + { + continue; + } + + var commit = LoadParent(id); + + foreach (var parentId in firstParentOnly ? commit.Parents.Take(1) : commit.Parents) + { + stack.Push(parentId); + } + } + + return reachable; + } + + private List PaintDownToCommon(GitObjectId first, GitObjectId second) + { + var walk = new PaintDownWalk(this); + walk.Enqueue(first, PaintDownWalk.FlagFirst); + walk.Enqueue(second, PaintDownWalk.FlagSecond); + return walk.Run(); + } + + /// + /// The paint-down-to-common walk of git merge-base: descend from both tips in + /// date order, painting each commit with the tips that reach it; commits reached by + /// both are candidates, and everything below a candidate is stale. The number of + /// queued non-stale entries is tracked incrementally: libgit2 rescans its whole queue + /// per iteration to decide whether anything interesting remains, which is quadratic + /// on the hottest path of version calculation. The count is equivalent to that scan. + /// + private sealed class PaintDownWalk(GitRevisionWalker walker) + { + public const int FlagFirst = 1; + public const int FlagSecond = 2; + private const int FlagStale = 4; + private const int FlagBoth = FlagFirst | FlagSecond; + + private readonly Dictionary flags = []; + private readonly Dictionary queuedCounts = []; + private readonly List candidates = []; + private readonly PriorityQueue queue = new(TimeDescendingComparer.Instance); + private long sequence; + private int interesting; + + public List Run() + { + while (this.interesting > 0) + { + Process(this.queue.Dequeue()); + } + + return this.candidates; + } + + public void Enqueue(GitObjectId id, int newFlags) + { + var current = this.flags.GetValueOrDefault(id); + var updated = current | newFlags; + + if (updated == current) + { + return; + } + + this.flags[id] = updated; + + if ((current & FlagStale) == 0 && (updated & FlagStale) != 0) + { + MarkQueuedEntriesStale(id); + } + + if (walker.TryLoad(id) is { } commit) + { + this.queue.Enqueue(commit, (commit.CommitterWhen.ToUnixTimeSeconds(), this.sequence++)); + this.queuedCounts[id] = this.queuedCounts.GetValueOrDefault(id) + 1; + + if ((updated & FlagStale) == 0) + { + this.interesting++; + } + } + } + + private void Process(GitCommit commit) + { + var commitFlags = this.flags[commit.Sha]; + this.queuedCounts[commit.Sha]--; + + if ((commitFlags & FlagStale) == 0) + { + this.interesting--; + } + + var paint = commitFlags & FlagBoth; + + if (paint == FlagBoth) + { + if ((commitFlags & FlagStale) == 0) + { + this.candidates.Add(commit.Sha); + this.flags[commit.Sha] = commitFlags | FlagStale; + MarkQueuedEntriesStale(commit.Sha); + } + + // Everything below a common commit is stale: it cannot be a better candidate. + paint |= FlagStale; + } + + foreach (var parentId in commit.Parents) + { + walker.LoadParent(parentId); + Enqueue(parentId, paint); + } + } + + private void MarkQueuedEntriesStale(GitObjectId id) => this.interesting -= this.queuedCounts.GetValueOrDefault(id); + } + + private GitObjectId? RemoveRedundantCandidates(List candidates) + { + // A candidate which is an ancestor of another candidate is redundant. Candidates are + // in discovery order (most recent first), so return the first independent one. + foreach (var candidate in candidates) + { + var others = candidates.Where(other => other != candidate).ToList(); + + if (!IsAncestorOfAny(candidate, others)) + { + return candidate; + } + } + + return candidates[0]; + } + + private bool IsAncestorOfAny(GitObjectId candidate, List others) + { + foreach (var other in others) + { + if (TryLoad(other) is not { } commit) + { + continue; + } + + if (CollectReachable(commit.Parents, firstParentOnly: false).Contains(candidate)) + { + return true; + } + } + + return false; + } + + /// + /// Loads a commit through the walker's parsed-commit cache, or returns + /// when the object does not exist. The cache is shared with the + /// adapter layer so each commit is read and parsed at most once per session. + /// + /// The id of the commit. + /// The commit, or . + public GitCommit? TryLoadCommit(GitObjectId id) => TryLoad(id); + + private GitCommit? TryLoad(GitObjectId id) + { + if (this.commitCache.TryGetValue(id, out var cached)) + { + return cached; + } + + GitCommit? commit = null; + + if (this.objectStore.TryGetObject(id, GitObjectTypes.Commit, out var stream)) + { + using (stream) + { + commit = GitCommitReader.Read(stream, id); + } + + if (this.shallowCommits.Contains(id)) + { + // Apply the shallow graft: the boundary commit is parentless even when its + // parent objects are present (e.g. via alternates), matching git and libgit2. + commit = commit.WithoutParents(); + } + } + + return this.commitCache.GetOrAdd(id, commit); + } + + /// + /// Loads a parent commit, failing the walk when the object is missing: with shallow + /// boundaries grafted away in , a missing parent means the + /// repository is corrupt or incomplete, and truncating history there would silently + /// produce a wrong version. + /// + /// The id of the parent commit. + /// The parent commit. + /// The parent object does not exist. + private GitCommit LoadParent(GitObjectId id) => + TryLoad(id) + ?? throw new GitObjectStoreException($"The parent commit '{id}' could not be found in the repository; the object database is corrupt or incomplete.") { ObjectNotFound = true }; + + /// + /// The per-walk engine. Mirrors libgit2's revwalk phases: seed preparation, the + /// date-directed limiting pass, and the per-sort emission disciplines. + /// + private sealed class WalkState(GitRevisionWalker walker, GitRevisionWalkOptions options) + { + private readonly Dictionary nodes = []; + + private bool FirstParentOnly => options.FirstParentOnly; + + public List Run() + { + var (seeds, limited) = PrepareSeeds(); + + if (limited) + { + seeds = LimitList(seeds); + } + + if (options.Sort.HasFlag(GitRevisionSortStrategies.Topological)) + { + return SortInTopologicalOrder(seeds, byTime: options.Sort.HasFlag(GitRevisionSortStrategies.Time)); + } + + if (options.Sort.HasFlag(GitRevisionSortStrategies.Time)) + { + return EmitByTime(seeds); + } + + return limited ? [.. seeds.Where(node => !node.Uninteresting)] : EmitUnsorted(seeds); + } + + private (List Seeds, bool Limited) PrepareSeeds() + { + var limited = options.Sort != GitRevisionSortStrategies.None; + + // Pushes prepend to the input list; includes are pushed before hides, + // and a hide overrides an earlier push of the same commit. + var userInput = new List(); + + foreach (var (id, uninteresting) in + options.Include.Select(id => (id, false)).Concat(options.Exclude.Select(id => (id, true)))) + { + var node = LookupNode(id); + + if (node.Uninteresting && !uninteresting) + { + continue; + } + + node.Uninteresting = uninteresting; + limited |= uninteresting; + userInput.Insert(0, node); + } + + var seeds = new List(); + + foreach (var node in userInput) + { + Parse(node); + + // A pushed or hidden commit whose object cannot be looked up fails the + // walk in libgit2, too; an unparsed seed would be emitted as a null commit. + if (!node.Parsed) + { + throw new GitObjectStoreException($"The commit '{node.Id}' could not be found in the repository.") { ObjectNotFound = true }; + } + + if (node.Uninteresting) + { + MarkParentsUninteresting(node); + } + + if (!node.Seen) + { + node.Seen = true; + seeds.Add(node); + } + } + + return (seeds, limited); + } + + /// + /// The date-directed limiting pass: traverses from the seeds in descending date order, + /// propagating "uninteresting" and stopping a few commits (the slop) after only + /// uninteresting commits remain. Returns the interesting commits in traversal order. + /// + private List LimitList(List seeds) + { + var list = new LinkedList(seeds); + var newList = new List(); + var slop = Slop; + var time = long.MaxValue; + + while (list.First is { } head) + { + list.RemoveFirst(); + var commit = head.Value; + + AddParentsToList(commit, list); + + if (commit.Uninteresting) + { + MarkParentsUninteresting(commit); + + slop = StillInteresting(list, time, slop); + + if (slop > 0) + { + continue; + } + + break; + } + + time = commit.Time; + newList.Add(commit); + } + + return newList; + } + + private void AddParentsToList(WalkNode commit, LinkedList list) + { + if (commit.Added) + { + return; + } + + commit.Added = true; + + if (commit.Uninteresting) + { + // Hidden histories ignore first-parent simplification: hide as much as possible. + foreach (var parent in ParsedParents(commit)) + { + parent.Uninteresting = true; + ParseParent(parent); + + if (parent.Parents.Count > 0) + { + MarkParentsUninteresting(parent); + } + + parent.Seen = true; + InsertByDate(list, parent); + } + + return; + } + + foreach (var parent in ParsedParents(commit)) + { + ParseParent(parent); + + if (!parent.Seen) + { + parent.Seen = true; + InsertByDate(list, parent); + } + + if (FirstParentOnly) + { + break; + } + } + } + + private static int StillInteresting(LinkedList list, long time, int slop) + { + if (list.Count == 0) + { + return 0; + } + + // A destination commit later than our current date means we are not done. + if (time <= list.First!.Value.Time) + { + return Slop; + } + + foreach (var item in list) + { + if (!item.Uninteresting || item.Time > time) + { + return Slop; + } + } + + return slop - 1; + } + + private static void InsertByDate(LinkedList list, WalkNode item) + { + // Keep the list in descending date order; equal dates go after existing entries. + var current = list.First; + + while (current is not null && current.Value.Time >= item.Time) + { + current = current.Next; + } + + if (current is null) + { + list.AddLast(item); + } + else + { + list.AddBefore(current, item); + } + } + + private static void MarkParentsUninteresting(WalkNode node) + { + // Mark all (currently parsed) ancestors uninteresting, chasing first-parent + // chains and stopping at commits which have not been parsed yet — the limiting + // pass will pick those up as it reaches them. + var pending = new Stack(); + + foreach (var parent in ParsedParents(node)) + { + pending.Push(parent); + } + + while (pending.Count > 0) + { + var commit = pending.Pop(); + + while (true) + { + if (commit.Uninteresting) + { + break; + } + + commit.Uninteresting = true; + + if (!commit.Parsed || commit.Parents.Count == 0) + { + break; + } + + foreach (var parent in commit.Parents) + { + pending.Push(parent); + } + + commit = commit.Parents[0]; + } + } + } + + private static List EmitByTime(List seeds) + { + var queue = new BinaryHeap(byTime: true); + + foreach (var node in seeds) + { + queue.Insert(node); + } + + var result = new List(); + + while (queue.Pop() is { } next) + { + if (!next.Uninteresting) + { + result.Add(next); + } + } + + return result; + } + + private List EmitUnsorted(List seeds) + { + // The default walk: pop the head of a date-sorted list, lazily inserting parents. + var list = new LinkedList(seeds); + var result = new List(); + + while (list.First is { } head) + { + list.RemoveFirst(); + var commit = head.Value; + + AddParentsToList(commit, list); + + if (!commit.Uninteresting) + { + result.Add(commit); + } + } + + return result; + } + + private static List SortInTopologicalOrder(List list, bool byTime) + { + // A commit may only be emitted after all commits in the list which have it as a + // parent. Ready commits are emitted chronologically when time-sorting, otherwise + // in the order the limiting pass produced them. + ComputeChildCounts(list); + + var queue = CreateReadyQueue(list, byTime); + var result = new List(); + + while (queue.Pop() is { } next) + { + foreach (var parent in next.Parents.Where(parent => parent.InDegree > 0)) + { + if (--parent.InDegree == 1) + { + queue.Insert(parent); + } + } + + next.InDegree = 0; + + if (!next.Uninteresting) + { + result.Add(next); + } + } + + return result; + } + + private static void ComputeChildCounts(List list) + { + foreach (var node in list) + { + node.InDegree = 1; + } + + foreach (var parent in list.SelectMany(node => node.Parents).Where(parent => parent.InDegree > 0)) + { + parent.InDegree++; + } + } + + private static BinaryHeap CreateReadyQueue(List list, bool byTime) + { + var queue = new BinaryHeap(byTime); + + foreach (var node in list.Where(node => node.InDegree == 1)) + { + queue.Insert(node); + } + + // The tips must come out in traversal order; without time-sorting the plain + // vector pops from the end, so reverse it to preserve the insertion order. + if (!byTime) + { + queue.Reverse(); + } + + return queue; + } + + private static IEnumerable ParsedParents(WalkNode node) + { + if (!node.Parsed) + { + yield break; + } + + foreach (var parent in node.Parents) + { + yield return parent; + } + } + + private WalkNode LookupNode(GitObjectId id) + { + if (!this.nodes.TryGetValue(id, out var node)) + { + node = new(id); + this.nodes.Add(id, node); + } + + return node; + } + + private void Parse(WalkNode node) + { + if (!node.Parsed && walker.TryLoad(node.Id) is { } commit) + { + Populate(node, commit); + } + } + + private void ParseParent(WalkNode node) + { + if (!node.Parsed) + { + Populate(node, walker.LoadParent(node.Id)); + } + } + + private void Populate(WalkNode node, GitCommit commit) + { + node.Commit = commit; + node.Time = commit.CommitterWhen.ToUnixTimeSeconds(); + node.Parents.AddRange(commit.Parents.Select(LookupNode)); + node.Parsed = true; + } + } + + private sealed class WalkNode(GitObjectId id) + { + public GitObjectId Id { get; } = id; + public GitCommit Commit { get; set; } = null!; + public long Time { get; set; } + public List Parents { get; } = []; + public bool Parsed { get; set; } + public bool Seen { get; set; } + public bool Uninteresting { get; set; } + public bool Added { get; set; } + public int InDegree { get; set; } + } + + /// + /// A binary min-heap over descending commit time, replicating the classic array-heap + /// mechanics (append + sift-up on insert; move-last-to-root + sift-down on pop, with + /// strict comparisons) so that the emission order of equal-timestamp commits matches + /// libgit2's. Without time ordering it degrades to a plain vector popped from the end. + /// + private sealed class BinaryHeap(bool byTime) + { + private readonly List items = []; + + public void Insert(WalkNode item) + { + this.items.Add(item); + + if (byTime) + { + SiftUp(this.items.Count - 1); + } + } + + public void Reverse() => this.items.Reverse(); + + public WalkNode? Pop() + { + if (this.items.Count == 0) + { + return null; + } + + if (!byTime) + { + var last = this.items[^1]; + this.items.RemoveAt(this.items.Count - 1); + return last; + } + + var result = this.items[0]; + + if (this.items.Count > 1) + { + this.items[0] = this.items[^1]; + this.items.RemoveAt(this.items.Count - 1); + SiftDown(0); + } + else + { + this.items.RemoveAt(0); + } + + return result; + } + + private static int Compare(WalkNode left, WalkNode right) => right.Time.CompareTo(left.Time); + + private void SiftUp(int index) + { + var item = this.items[index]; + + while (index > 0) + { + var parentIndex = (index - 1) >> 1; + var parent = this.items[parentIndex]; + + if (Compare(parent, item) <= 0) + { + break; + } + + this.items[index] = parent; + index = parentIndex; + } + + this.items[index] = item; + } + + private void SiftDown(int index) + { + var item = this.items[index]; + + while (true) + { + var childIndex = (index << 1) + 1; + + if (childIndex >= this.items.Count) + { + break; + } + + if (childIndex + 1 < this.items.Count && Compare(this.items[childIndex], this.items[childIndex + 1]) > 0) + { + childIndex++; + } + + if (Compare(item, this.items[childIndex]) <= 0) + { + break; + } + + this.items[index] = this.items[childIndex]; + index = childIndex; + } + + this.items[index] = item; + } + } + + private sealed class TimeDescendingComparer : IComparer<(long Time, long Sequence)> + { + public static TimeDescendingComparer Instance { get; } = new(); + + public int Compare((long Time, long Sequence) x, (long Time, long Sequence) y) + { + var byTime = y.Time.CompareTo(x.Time); + return byTime != 0 ? byTime : x.Sequence.CompareTo(y.Sequence); + } + } +} diff --git a/src/GitVersion.Git.Managed/NOTICE.md b/src/GitVersion.Git.Managed/NOTICE.md new file mode 100644 index 0000000000..2f19193be0 --- /dev/null +++ b/src/GitVersion.Git.Managed/NOTICE.md @@ -0,0 +1,35 @@ +# Third-party notices + +## Nerdbank.GitVersioning + +Portions of the code in this project (the managed Git object store reader) are derived from +[Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning), specifically its +`ManagedGit` implementation. + +Nerdbank.GitVersioning is licensed under the MIT License: + +``` +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/src/GitVersion.Git.Managed/PublicAPI.Shipped.txt b/src/GitVersion.Git.Managed/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/GitVersion.Git.Managed/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/GitVersion.Git.Managed/PublicAPI.Unshipped.txt b/src/GitVersion.Git.Managed/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..b7be404d17 --- /dev/null +++ b/src/GitVersion.Git.Managed/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +#nullable enable +GitVersion.GitVersionManagedGitModule +GitVersion.GitVersionManagedGitModule.GitVersionManagedGitModule() -> void +GitVersion.GitVersionManagedGitModule.RegisterTypes(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> void diff --git a/src/GitVersion.Git.Managed/RandomAccessStream.cs b/src/GitVersion.Git.Managed/RandomAccessStream.cs new file mode 100644 index 0000000000..f6a8789e75 --- /dev/null +++ b/src/GitVersion.Git.Managed/RandomAccessStream.cs @@ -0,0 +1,98 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using Microsoft.Win32.SafeHandles; + +namespace GitVersion.Git; + +/// +/// Provides read-only, seekable access to a file through a shared . +/// Each instance maintains its own position, so multiple streams can read the same file +/// concurrently without interfering with each other. The handle is not owned by this stream. +/// +internal sealed class RandomAccessStream : Stream +{ + private readonly SafeFileHandle handle; + private readonly long length; + + /// + /// Initializes a new instance of the class. + /// + /// The handle of the file to read. Remains owned by the caller. + /// The length of the file. + public RandomAccessStream(SafeFileHandle handle, long length) + { + this.handle = handle ?? throw new ArgumentNullException(nameof(handle)); + this.length = 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; + set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value), value, "A stream position cannot be negative."); + } + + /// + public override void Flush() + { + } + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span buffer) + { + var toRead = (int)Math.Min(buffer.Length, this.length - Position); + if (toRead <= 0) + { + return 0; + } + + var read = RandomAccess.Read(this.handle, buffer[..toRead], Position); + Position += read; + return read; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + var newPosition = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => Position + offset, + _ => 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."); + } + + Position = newPosition; + return 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/GitVersion.Git.Managed/Refs/GitReference.cs b/src/GitVersion.Git.Managed/Refs/GitReference.cs new file mode 100644 index 0000000000..8da594e8c2 --- /dev/null +++ b/src/GitVersion.Git.Managed/Refs/GitReference.cs @@ -0,0 +1,46 @@ +namespace GitVersion.Git; + +/// +/// Represents a Git reference: either a direct reference pointing at an object, +/// or a symbolic reference pointing at another reference. +/// +internal sealed class GitReference +{ + /// + /// Gets the canonical name of the reference, e.g. refs/heads/main or HEAD. + /// + public required string CanonicalName { get; init; } + + /// + /// Gets the object id the reference points at, for direct references. + /// + public GitObjectId? ObjectId { get; init; } + + /// + /// Gets the canonical name of the reference this reference points at, for symbolic references. + /// + public string? SymbolicTargetName { get; init; } + + /// + /// Gets the fully peeled target of the reference (the commit an annotated tag ultimately + /// points at), when known from the packed-refs file; otherwise . + /// + public GitObjectId? PeeledObjectId { get; init; } + + /// + /// Gets a value indicating whether this reference was read from the packed-refs + /// file (as opposed to a loose reference file). + /// + public bool IsPacked { get; init; } + + /// + /// Gets a value indicating whether this is a symbolic reference. + /// + public bool IsSymbolic => SymbolicTargetName is not null; + + /// + public override string ToString() => + IsSymbolic + ? $"{CanonicalName} -> {SymbolicTargetName}" + : $"{CanonicalName} -> {ObjectId}"; +} diff --git a/src/GitVersion.Git.Managed/Refs/GitReferenceStore.cs b/src/GitVersion.Git.Managed/Refs/GitReferenceStore.cs new file mode 100644 index 0000000000..57c05fceb6 --- /dev/null +++ b/src/GitVersion.Git.Managed/Refs/GitReferenceStore.cs @@ -0,0 +1,242 @@ +namespace GitVersion.Git; + +/// +/// Provides read-only access to the references of a Git repository: loose refs under +/// refs/, the packed-refs file (including peeled targets), and per-worktree +/// refs such as HEAD. +/// +/// +/// Loose references take precedence over entries in packed-refs, matching git. +/// +internal sealed class GitReferenceStore +{ + private const string RefsPrefix = "refs/"; + + // Matches git's limit on the depth of nested symbolic references (MAXDEPTH in refs.c). + private const int MaxSymbolicReferenceDepth = 5; + + private readonly string gitDirectory; + private readonly string commonDirectory; + private readonly Lazy> packedReferences; + + /// + /// Initializes a new instance of the class. + /// + /// The (per-worktree) git directory, which contains HEAD. + /// + /// The common git directory, which contains refs/ and packed-refs. + /// Defaults to . + /// + public GitReferenceStore(string gitDirectory, string? commonDirectory = null) + { + ArgumentNullException.ThrowIfNull(gitDirectory); + + this.gitDirectory = Path.GetFullPath(gitDirectory); + this.commonDirectory = commonDirectory is null ? this.gitDirectory : Path.GetFullPath(commonDirectory); + this.packedReferences = new(ReadPackedReferences); + } + + /// + /// Reads a single reference by its canonical name, without following symbolic references. + /// + /// The canonical name, e.g. refs/heads/main or HEAD. + /// The reference, or if it does not exist. + public GitReference? GetReference(string canonicalName) + { + ArgumentNullException.ThrowIfNull(canonicalName); + + return ReadLooseReference(canonicalName) + ?? (this.packedReferences.Value.TryGetValue(canonicalName, out var packed) ? packed : null); + } + + /// + /// Reads the HEAD reference of the (worktree's) repository. + /// + /// The HEAD reference, or if it does not exist. + public GitReference? GetHead() => GetReference("HEAD"); + + /// + /// Resolves a reference to a direct reference, following symbolic references. + /// + /// The canonical name, e.g. HEAD or refs/heads/main. + /// + /// The direct reference at the end of the symbolic chain, or when the + /// reference does not exist (e.g. HEAD pointing at an unborn branch). + /// + public GitReference? Resolve(string canonicalName) + { + var current = GetReference(canonicalName); + var depth = 0; + + while (current is { IsSymbolic: true }) + { + if (++depth > MaxSymbolicReferenceDepth) + { + throw new GitObjectStoreException($"The symbolic reference '{canonicalName}' is nested more than {MaxSymbolicReferenceDepth} levels deep."); + } + + current = GetReference(current.SymbolicTargetName!); + } + + return current; + } + + /// + /// Resolves a reference to the object id it (ultimately) points at. + /// + /// The canonical name, e.g. HEAD or refs/tags/v1.0.0. + /// The object id, or when the reference does not exist. + public GitObjectId? ResolveToObjectId(string canonicalName) => Resolve(canonicalName)?.ObjectId; + + /// + /// Enumerates the references whose canonical name starts with the given prefix, + /// in ordinal name order. Loose references shadow packed references of the same name. + /// + /// The prefix to filter by, e.g. refs/, refs/heads/ or refs/tags/. + /// The matching references. + public IEnumerable EnumerateReferences(string prefix = RefsPrefix) + { + ArgumentNullException.ThrowIfNull(prefix); + + var references = new SortedDictionary(StringComparer.Ordinal); + + foreach (var packed in this.packedReferences.Value.Values + .Where(reference => reference.CanonicalName.StartsWith(prefix, StringComparison.Ordinal))) + { + references[packed.CanonicalName] = packed; + } + + var refsRoot = Path.Combine(this.commonDirectory, "refs"); + + if (Directory.Exists(refsRoot)) + { + foreach (var file in Directory.EnumerateFiles(refsRoot, "*", SearchOption.AllDirectories)) + { + // Skip transient lock files created while git updates a reference, matching git/libgit2. + if (file.EndsWith(".lock", StringComparison.Ordinal)) + { + continue; + } + + var canonicalName = RefsPrefix + Path.GetRelativePath(refsRoot, file).Replace(Path.DirectorySeparatorChar, '/'); + + if (!canonicalName.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + if (ReadLooseReference(canonicalName) is { } reference) + { + references[canonicalName] = reference; + } + } + } + + return references.Values; + } + + private GitReference? ReadLooseReference(string canonicalName) + { + // HEAD and other pseudo-refs (ORIG_HEAD, FETCH_HEAD, ...) are per-worktree and live in + // the git directory; everything under refs/ is shared and lives in the common directory. + var directory = canonicalName.StartsWith(RefsPrefix, StringComparison.Ordinal) + ? this.commonDirectory + : this.gitDirectory; + + var path = Path.Combine(directory, canonicalName.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(path)) + { + return null; + } + + string? line; + + using (var reader = new StreamReader(path, Encoding.UTF8)) + { + line = reader.ReadLine(); + } + + line = line?.Trim(); + + if (string.IsNullOrEmpty(line)) + { + return null; + } + + if (line.StartsWith("ref: ", StringComparison.Ordinal)) + { + return new() + { + CanonicalName = canonicalName, + SymbolicTargetName = line["ref: ".Length..].Trim() + }; + } + + return new() + { + CanonicalName = canonicalName, + ObjectId = GitObjectId.Parse(line) + }; + } + + private Dictionary ReadPackedReferences() + { + var references = new Dictionary(StringComparer.Ordinal); + var packedRefsPath = Path.Combine(this.commonDirectory, "packed-refs"); + + if (!File.Exists(packedRefsPath)) + { + return references; + } + + string? previousName = null; + + foreach (var rawLine in File.ReadLines(packedRefsPath, Encoding.UTF8)) + { + var line = rawLine.Trim(); + + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } + + if (line.StartsWith('^')) + { + // A peeled line holds the fully-peeled target of the preceding annotated tag. + if (previousName is null) + { + throw new GitObjectStoreException("The packed-refs file is malformed: a peeled line has no preceding reference."); + } + + var peeled = references[previousName]; + references[previousName] = new() + { + CanonicalName = peeled.CanonicalName, + ObjectId = peeled.ObjectId, + PeeledObjectId = GitObjectId.Parse(line[1..]), + IsPacked = true + }; + continue; + } + + var separator = line.IndexOf(' '); + + if (separator <= 0 || separator == line.Length - 1) + { + throw new GitObjectStoreException("The packed-refs file is malformed: a line does not contain an object id and a reference name."); + } + + var name = line[(separator + 1)..]; + references[name] = new() + { + CanonicalName = name, + ObjectId = GitObjectId.Parse(line[..separator]), + IsPacked = true + }; + previousName = name; + } + + return references; + } +} diff --git a/src/GitVersion.Git.Managed/Repository/GitConfigurationFile.cs b/src/GitVersion.Git.Managed/Repository/GitConfigurationFile.cs new file mode 100644 index 0000000000..9ab31fe735 --- /dev/null +++ b/src/GitVersion.Git.Managed/Repository/GitConfigurationFile.cs @@ -0,0 +1,241 @@ +namespace GitVersion.Git; + +/// +/// A minimal parser for the repository-local .git/config file, covering the entries +/// GitVersion needs: [remote "name"] (url/fetch/push) and [branch "name"] +/// (remote/merge). Section names and keys are case-insensitive, subsection names are +/// case-sensitive, and later values override earlier ones (git's last-one-wins semantics). +/// +internal sealed class GitConfigurationFile +{ + private readonly List<(string Section, string? Subsection, string Key, string Value)> entries; + + private GitConfigurationFile(List<(string Section, string? Subsection, string Key, string Value)> entries) => this.entries = entries; + + /// + /// Loads a configuration file, returning an empty configuration when the file does not exist. + /// + /// The path of the configuration file. + /// The parsed configuration. + public static GitConfigurationFile Load(string path) + { + var entries = new List<(string, string?, string, string)>(); + + if (!File.Exists(path)) + { + return new(entries); + } + + string? section = null; + string? subsection = null; + + foreach (var rawLine in File.ReadLines(path, Encoding.UTF8)) + { + var line = rawLine.Trim(); + + if (line.Length == 0 || line.StartsWith('#') || line.StartsWith(';')) + { + continue; + } + + if (line.StartsWith('[')) + { + (section, subsection) = ParseSectionHeader(line); + continue; + } + + if (section is null) + { + continue; + } + + var separator = line.IndexOf('='); + string key; + string value; + + if (separator < 0) + { + // A key without '=' is a boolean true. + key = StripComment(line).Trim(); + value = "true"; + } + else + { + key = line[..separator].Trim(); + value = ParseValue(line[(separator + 1)..]); + } + + if (key.Length > 0) + { + entries.Add((section, subsection, key.ToLowerInvariant(), value)); + } + } + + return new(entries); + } + + /// + /// Gets the effective (last) value of a key, or when not present. + /// + public string? GetString(string section, string? subsection, string key) + { + var values = GetAll(section, subsection, key); + return values.Count > 0 ? values[^1] : null; + } + + /// + /// Gets the effective value of a boolean key using git's boolean semantics, + /// or when the key is not present or not a valid boolean. + /// + public bool? GetBoolean(string section, string? subsection, string key) => + GetString(section, subsection, key) switch + { + null => null, + "" => false, + var value when value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("on", StringComparison.OrdinalIgnoreCase) => true, + var value when value.Equals("false", StringComparison.OrdinalIgnoreCase) + || value.Equals("no", StringComparison.OrdinalIgnoreCase) + || value.Equals("off", StringComparison.OrdinalIgnoreCase) => false, + var value when long.TryParse(value, out var number) => number != 0, + _ => null + }; + + /// + /// Gets all values of a multi-valued key, in file order. + /// + public IReadOnlyList GetAll(string section, string? subsection, string key) => + [.. this.entries + .Where(entry => entry.Section.Equals(section, StringComparison.OrdinalIgnoreCase) + && string.Equals(entry.Subsection, subsection, StringComparison.Ordinal) + && entry.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) + .Select(entry => entry.Value)]; + + /// + /// Gets the distinct subsection names of a section, in file order. + /// + public IReadOnlyList GetSubsections(string section) => + [.. this.entries + .Where(entry => entry.Section.Equals(section, StringComparison.OrdinalIgnoreCase) && entry.Subsection is not null) + .Select(entry => entry.Subsection!) + .Distinct(StringComparer.Ordinal)]; + + private static (string Section, string? Subsection) ParseSectionHeader(string line) + { + var end = line.IndexOf(']'); + var header = end > 0 ? line[1..end] : line[1..]; + var space = header.IndexOf(' '); + + if (space < 0) + { + // Handle the deprecated [section.subsection] form as a plain section name. + return (header.Trim().ToLowerInvariant(), null); + } + + var section = header[..space].Trim().ToLowerInvariant(); + var subsection = header[space..].Trim(); + + if (subsection.StartsWith('"') && subsection.EndsWith('"') && subsection.Length >= 2) + { + subsection = subsection[1..^1].Replace("\\\\", "\\").Replace("\\\"", "\""); + } + + return (section, subsection); + } + + private static string ParseValue(string raw) + { + // git config values are unescaped regardless of whether they are quoted: backslash + // escapes (\\, \", \n, \t, \b) and double-quoted spans are processed inline, so an + // unquoted Windows path stored as D:\\a\\repo decodes back to D:\a\repo. Comments + // (# or ;) and trailing whitespace are only significant outside quotes. + var index = SkipLeadingWhitespace(raw); + var result = new StringBuilder(raw.Length - index); + var inQuotes = false; + var contentEnd = 0; + + while (index < raw.Length) + { + var current = raw[index]; + + if (current == '\\') + { + if (index + 1 >= raw.Length) + { + break; + } + + result.Append(DecodeEscape(raw[index + 1])); + contentEnd = result.Length; + index += 2; + continue; + } + + index++; + + if (current == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && IsCommentStart(current)) + { + break; + } + + result.Append(current); + if (inQuotes || !IsWhitespace(current)) + { + contentEnd = result.Length; + } + } + + return result.ToString(0, contentEnd); + } + + private static int SkipLeadingWhitespace(string raw) + { + var index = 0; + while (index < raw.Length && IsWhitespace(raw[index])) + { + index++; + } + + return index; + } + + private static bool IsWhitespace(char value) => value is ' ' or '\t'; + + private static bool IsCommentStart(char value) => value is '#' or ';'; + + private static char DecodeEscape(char escaped) => escaped switch + { + 'n' => '\n', + 't' => '\t', + 'b' => '\b', + _ => escaped // covers \\ and \" and is lenient for any other escape + }; + + private static string StripComment(string value) + { + var inQuotes = false; + + for (var i = 0; i < value.Length; i++) + { + var current = value[i]; + + if (current == '"' && (i == 0 || value[i - 1] != '\\')) + { + inQuotes = !inQuotes; + } + else if (current is '#' or ';' && !inQuotes) + { + return value[..i]; + } + } + + return value; + } +} diff --git a/src/GitVersion.Git.Managed/Repository/GitRepositoryLayout.cs b/src/GitVersion.Git.Managed/Repository/GitRepositoryLayout.cs new file mode 100644 index 0000000000..ff6945e7c7 --- /dev/null +++ b/src/GitVersion.Git.Managed/Repository/GitRepositoryLayout.cs @@ -0,0 +1,211 @@ +namespace GitVersion.Git; + +/// +/// Describes the on-disk layout of a Git repository: where its git directory, common +/// directory and working directory are, and how to open its object and reference stores. +/// Handles regular repositories, bare repositories, linked worktrees (.git files +/// and commondir indirection) and shallow clones. +/// +internal sealed class GitRepositoryLayout +{ + private const string GitDirectoryOrFileName = ".git"; + private const string GitDirFilePrefix = "gitdir:"; + + /// + /// Gets the (per-worktree) git directory, which contains HEAD. + /// + public required string GitDirectory { get; init; } + + /// + /// Gets the common git directory, which contains objects/, refs/ and + /// packed-refs. Equal to except for linked worktrees. + /// + public required string CommonDirectory { get; init; } + + /// + /// Gets the root of the working directory, or for bare repositories. + /// + public string? WorkingDirectory { get; init; } + + /// + /// Gets the path to the object database of the repository. + /// + public string ObjectsDirectory => Path.Combine(CommonDirectory, "objects"); + + /// + /// Gets a value indicating whether the repository is a shallow clone. + /// + public bool IsShallow => File.Exists(ShallowFilePath); + + private string ShallowFilePath => Path.Combine(CommonDirectory, "shallow"); + + /// + /// Discovers the repository containing by walking up the + /// directory hierarchy, resolving .git files (linked worktrees, submodules) and + /// the commondir indirection. + /// + /// The path at which to start the discovery. + /// The layout of the discovered repository. + /// No repository was found. + public static GitRepositoryLayout Discover(string startPath) => + TryDiscover(startPath) + ?? throw new GitObjectStoreException($"No git repository was found at or above '{startPath}'."); + + /// + /// Discovers the repository containing by walking up the + /// directory hierarchy, resolving .git files (linked worktrees, submodules) and + /// the commondir indirection. + /// + /// The path at which to start the discovery. + /// The layout of the discovered repository, or if none was found. + public static GitRepositoryLayout? TryDiscover(string startPath) + { + ArgumentNullException.ThrowIfNull(startPath); + + for (var current = Path.GetFullPath(startPath); current is not null; current = Path.GetDirectoryName(current)) + { + if (TryOpenExact(current) is { } layout) + { + return layout; + } + } + + return null; + } + + /// + /// Opens the repository at exactly (a working directory with a + /// .git entry, or a git directory itself) without walking up the hierarchy — + /// the equivalent of libgit2's new Repository(path) as opposed to its discovery. + /// + /// The path of the repository. + /// The layout of the repository, or when the path is not itself a repository. + public static GitRepositoryLayout? TryOpen(string path) + { + ArgumentNullException.ThrowIfNull(path); + return TryOpenExact(Path.GetFullPath(path)); + } + + private static GitRepositoryLayout? TryOpenExact(string current) + { + var dotGit = Path.Combine(current, GitDirectoryOrFileName); + + if (Directory.Exists(dotGit)) + { + return FromGitDirectory(dotGit, current); + } + + if (File.Exists(dotGit)) + { + return FromGitDirectory(ResolveGitDirFile(dotGit), current); + } + + if (IsGitDirectory(current)) + { + // A bare repository, or the .git directory itself. + return FromGitDirectory(current, workingDirectory: null); + } + + return null; + } + + /// + /// Creates the layout for a known git directory, resolving the commondir indirection + /// used by linked worktrees. + /// + /// The git directory. + /// The root of the working directory, or for bare repositories. + /// The layout of the repository. + public static GitRepositoryLayout FromGitDirectory(string gitDirectory, string? workingDirectory) + { + ArgumentNullException.ThrowIfNull(gitDirectory); + + gitDirectory = Path.GetFullPath(gitDirectory); + var commonDirectory = gitDirectory; + + // Linked worktrees store the path of the main repository's git directory in a 'commondir' file. + var commonDirFile = Path.Combine(gitDirectory, "commondir"); + + if (File.Exists(commonDirFile)) + { + var target = File.ReadAllText(commonDirFile).Trim(); + + commonDirectory = Path.IsPathRooted(target) + ? Path.GetFullPath(target) + : Path.GetFullPath(Path.Combine(gitDirectory, target)); + } + + if (Directory.Exists(Path.Combine(commonDirectory, "reftable"))) + { + throw new NotSupportedException("Repositories using the reftable reference storage format are not supported yet."); + } + + // SHA-256 repositories declare extensions.objectformat in their config. The managed + // reader only implements SHA-1 pack indexes so far; fail clearly at open instead of + // deep inside an object lookup (parity: libgit2 cannot read them either). + var objectFormat = GitConfigurationFile.Load(Path.Combine(commonDirectory, "config")) + .GetString("extensions", null, "objectformat"); + if (objectFormat is not null && !objectFormat.Equals("sha1", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException($"Repositories using the '{objectFormat}' object format are not supported yet."); + } + + return new() + { + GitDirectory = gitDirectory, + CommonDirectory = commonDirectory, + WorkingDirectory = workingDirectory + }; + } + + /// + /// Opens the object store of the repository. + /// + /// A new over the repository's object database. + public GitObjectStore CreateObjectStore() => new(ObjectsDirectory); + + /// + /// Opens the reference store of the repository. + /// + /// A new over the repository's references. + public GitReferenceStore CreateReferenceStore() => new(GitDirectory, CommonDirectory); + + /// + /// Reads the commit ids at the boundary of a shallow clone from the shallow file. + /// + /// The shallow boundary commits, or an empty list when the repository is not shallow. + public IReadOnlyList ReadShallowCommits() + { + if (!IsShallow) + { + return []; + } + + return [.. File.ReadLines(ShallowFilePath, Encoding.UTF8) + .Select(line => line.Trim()) + .Where(line => line.Length > 0) + .Select(GitObjectId.Parse)]; + } + + private static bool IsGitDirectory(string path) => + File.Exists(Path.Combine(path, "HEAD")) + && Directory.Exists(Path.Combine(path, "objects")) + && Directory.Exists(Path.Combine(path, "refs")); + + private static string ResolveGitDirFile(string dotGitFile) + { + // Format: "gitdir: ", where the path may be relative to the file's directory. + var content = File.ReadAllText(dotGitFile).Trim(); + + if (!content.StartsWith(GitDirFilePrefix, StringComparison.Ordinal)) + { + throw new GitObjectStoreException($"The .git file '{dotGitFile}' is malformed: it does not start with '{GitDirFilePrefix}'."); + } + + var target = content[GitDirFilePrefix.Length..].Trim(); + + return Path.IsPathRooted(target) + ? Path.GetFullPath(target) + : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(dotGitFile)!, target)); + } +} diff --git a/src/GitVersion.Git.Managed/SafeFileHandleExtensions.cs b/src/GitVersion.Git.Managed/SafeFileHandleExtensions.cs new file mode 100644 index 0000000000..757f7ef6b7 --- /dev/null +++ b/src/GitVersion.Git.Managed/SafeFileHandleExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Win32.SafeHandles; + +namespace GitVersion.Git; + +/// +/// Provides extension methods for reading files through a . +/// +internal static class SafeFileHandleExtensions +{ + /// + /// Fills with the bytes stored at in the file, + /// without affecting any stream position. + /// + /// The handle of the file to read from. + /// The offset in the file at which to start reading. + /// The buffer to fill. + /// Thrown when the file ends before could be filled. + public static void ReadExactlyAt(this SafeFileHandle handle, long offset, Span buffer) + { + var totalRead = 0; + + while (totalRead < buffer.Length) + { + var read = RandomAccess.Read(handle, buffer[totalRead..], offset + totalRead); + if (read == 0) + { + throw new EndOfStreamException(); + } + + totalRead += read; + } + } +} diff --git a/src/GitVersion.Git.Managed/Status/GitIgnoreRules.cs b/src/GitVersion.Git.Managed/Status/GitIgnoreRules.cs new file mode 100644 index 0000000000..4185795e70 --- /dev/null +++ b/src/GitVersion.Git.Managed/Status/GitIgnoreRules.cs @@ -0,0 +1,215 @@ +using System.Text.RegularExpressions; + +namespace GitVersion.Git; + +/// +/// The parsed rules of a single ignore source (a .gitignore file or +/// .git/info/exclude), matched against paths relative to the source's directory. +/// +/// +internal sealed class GitIgnoreRules +{ + private readonly List<(Regex Pattern, bool DirectoryOnly, bool Negated)> rules = []; + + private GitIgnoreRules(IEnumerable lines, bool ignoreCase) + { + foreach (var line in lines) + { + if (ParseLine(line, ignoreCase) is { } rule) + { + this.rules.Add(rule); + } + } + } + + /// + /// Loads the rules from an ignore file, or returns when the file does not exist. + /// + /// The path of the ignore file. + /// Whether patterns match case-insensitively (core.ignorecase). + /// The parsed rules, if the file exists. + public static GitIgnoreRules? Load(string path, bool ignoreCase) => File.Exists(path) ? new(File.ReadLines(path), ignoreCase) : null; + + /// + /// Parses ignore rules from text lines. + /// + /// The lines of an ignore file. + /// Whether patterns match case-insensitively (core.ignorecase). + /// The parsed rules. + public static GitIgnoreRules Parse(IEnumerable lines, bool ignoreCase) => new(lines, ignoreCase); + + /// + /// Determines whether this source decides the ignored state of the given path. + /// The last matching rule wins, per gitignore semantics. + /// + /// The path, relative to this source's directory, using forward slashes. + /// Whether the path refers to a directory. + /// + /// (ignored), (explicitly re-included), + /// or when no rule matches. + /// + public bool? IsIgnored(string relativePath, bool isDirectory) + { + for (var i = this.rules.Count - 1; i >= 0; i--) + { + var (pattern, directoryOnly, negated) = this.rules[i]; + + if (directoryOnly && !isDirectory) + { + continue; + } + + if (pattern.IsMatch(relativePath)) + { + return !negated; + } + } + + return null; + } + + private static (Regex Pattern, bool DirectoryOnly, bool Negated)? ParseLine(string line, bool ignoreCase) + { + var pattern = TrimTrailingSpaces(line); + + if (pattern.Length == 0 || pattern[0] == '#') + { + return null; + } + + var negated = pattern[0] == '!'; + + if (negated) + { + pattern = pattern[1..]; + } + + var directoryOnly = pattern.EndsWith('/'); + + if (directoryOnly) + { + pattern = pattern[..^1]; + } + + if (pattern.Length == 0) + { + return null; + } + + // Patterns containing a slash are anchored to the directory of the ignore file, + // while all other patterns match at any depth below it. + if (!pattern.Contains('/')) + { + pattern = "**/" + pattern; + } + + pattern = pattern.TrimStart('/'); + + // git matches ignore patterns case-insensitively when core.ignorecase is set + // (the default in repositories created on case-insensitive filesystems). + var regexOptions = RegexOptions.CultureInvariant | (ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); + var regex = new Regex( + "^" + TranslateToRegex(pattern) + "$", + regexOptions, + TimeSpan.FromSeconds(1)); + + return (regex, directoryOnly, negated); + } + + private static string TrimTrailingSpaces(string line) + { + var end = line.Length; + + while (end > 0 && line[end - 1] == ' ' && (end < 2 || line[end - 2] != '\\')) + { + end--; + } + + return line[..end]; + } + + private static string TranslateToRegex(string pattern) + { + var regex = new StringBuilder(); + var i = 0; + + while (i < pattern.Length) + { + var atSegmentStart = i == 0 || pattern[i - 1] == '/'; + + if (atSegmentStart && pattern.AsSpan(i).StartsWith("**/", StringComparison.Ordinal)) + { + regex.Append("(?:[^/]+/)*"); + i += 3; + continue; + } + + if (atSegmentStart && pattern.AsSpan(i).SequenceEqual("**")) + { + regex.Append(".*"); + i += 2; + continue; + } + + var current = pattern[i]; + + switch (current) + { + case '*' when i + 1 < pattern.Length && pattern[i + 1] == '*': + // Consecutive asterisks that are not at a path boundary act as + // regular asterisks, per the gitignore specification. + regex.Append("[^/]*"); + i += 2; + break; + + case '*': + regex.Append("[^/]*"); + i++; + break; + + case '?': + regex.Append("[^/]"); + i++; + break; + + case '[': + i = AppendCharacterClass(pattern, i, regex); + break; + + case '\\' when i + 1 < pattern.Length: + regex.Append(Regex.Escape(pattern[i + 1].ToString())); + i += 2; + break; + + default: + regex.Append(Regex.Escape(current.ToString())); + i++; + break; + } + } + + return regex.ToString(); + } + + private static int AppendCharacterClass(string pattern, int start, StringBuilder regex) + { + var end = pattern.IndexOf(']', start + 1); + + if (end < 0) + { + // An unterminated class matches a literal bracket. + regex.Append(Regex.Escape("[")); + return start + 1; + } + + var body = pattern[(start + 1)..end]; + + if (body.StartsWith('!')) + { + body = "^" + body[1..]; + } + + regex.Append('[').Append(body).Append(']'); + return end + 1; + } +} diff --git a/src/GitVersion.Git.Managed/Status/GitIndex.cs b/src/GitVersion.Git.Managed/Status/GitIndex.cs new file mode 100644 index 0000000000..c3aea220e0 --- /dev/null +++ b/src/GitVersion.Git.Managed/Status/GitIndex.cs @@ -0,0 +1,203 @@ +using System.Buffers.Binary; + +namespace GitVersion.Git; + +/// +/// A single entry of the .git/index file. +/// +/// The repository-relative path, using forward slashes. +/// The file mode, e.g. 0b1000_000_110_100_100 (100644 octal) for a regular file. +/// The object id of the staged blob. +/// The merge stage: 0 for a normal entry, 1–3 during conflicts. +/// The cached on-disk file size (truncated to 32 bits). +/// The cached modification time, in seconds since the epoch. +/// The nanosecond fraction of the cached modification time. +/// Whether the assume-unchanged bit is set. +/// Whether the skip-worktree bit is set (sparse checkout). +/// Whether the intent-to-add bit is set (git add -N). +internal sealed record GitIndexEntry( + string Path, + uint Mode, + GitObjectId ObjectId, + int Stage, + uint Size, + uint ModificationTimeSeconds, + uint ModificationTimeNanoseconds, + bool AssumeValid, + bool SkipWorktree, + bool IntentToAdd) +{ + /// + /// Gets a value indicating whether the entry's file mode has the executable bit set. + /// + public bool IsExecutable => (Mode & 0b001_000_000) != 0; + + /// + /// Gets a value indicating whether the entry is a symbolic link (mode 120000). + /// + public bool IsSymbolicLink => (Mode & 0b1111_000_000_000_000) == 0b1010_000_000_000_000; + + /// + /// Gets a value indicating whether the entry is a gitlink/submodule (mode 160000). + /// + public bool IsGitLink => (Mode & 0b1111_000_000_000_000) == 0b1110_000_000_000_000; +} + +/// +/// Reads the .git/index file (the staging area), versions 2 to 4. +/// +/// +internal sealed class GitIndex +{ + private const int HeaderLength = 12; + private const int EntryFixedLength = 62; + + private GitIndex(int version, IReadOnlyList entries) + { + Version = version; + Entries = entries; + } + + /// + /// Gets the format version of the index file: 2, 3 or 4. + /// + public int Version { get; } + + /// + /// Gets the entries of the index, in the order stored (sorted by path and stage). + /// + public IReadOnlyList Entries { get; } + + /// + /// Reads an index file from disk. A missing file yields an empty index. + /// + /// The path to the index file. + /// The parsed index. + public static GitIndex Read(string path) => + File.Exists(path) ? Parse(File.ReadAllBytes(path)) : new(2, []); + + /// + /// Parses the binary content of an index file. + /// + /// The raw bytes of the index file. + /// The parsed index. + public static GitIndex Parse(ReadOnlySpan data) + { + if (data.Length < HeaderLength || !data[..4].SequenceEqual("DIRC"u8)) + { + throw new GitObjectStoreException("The index file has an invalid signature."); + } + + var version = BinaryPrimitives.ReadInt32BigEndian(data[4..8]); + + if (version is < 2 or > 4) + { + throw new GitObjectStoreException($"Index version {version} is not supported; only versions 2 to 4 are supported."); + } + + var entryCount = BinaryPrimitives.ReadInt32BigEndian(data[8..12]); + var entries = new List(entryCount); + var offset = HeaderLength; + var previousPath = ""; + + for (var i = 0; i < entryCount; i++) + { + (var entry, offset) = ReadEntry(data, offset, version, previousPath); + entries.Add(entry); + previousPath = entry.Path; + } + + // Extensions and the trailing checksum are not needed and are skipped. + return new(version, entries); + } + + private static (GitIndexEntry Entry, int Offset) ReadEntry(ReadOnlySpan data, int offset, int version, string previousPath) + { + var entryStart = offset; + var fixedPart = data.Slice(offset, EntryFixedLength); + + var modificationSeconds = BinaryPrimitives.ReadUInt32BigEndian(fixedPart[8..12]); + var modificationNanoseconds = BinaryPrimitives.ReadUInt32BigEndian(fixedPart[12..16]); + var mode = BinaryPrimitives.ReadUInt32BigEndian(fixedPart[24..28]); + var size = BinaryPrimitives.ReadUInt32BigEndian(fixedPart[36..40]); + var objectId = GitObjectId.Parse(fixedPart.Slice(40, GitObjectId.Sha1Size)); + var flags = BinaryPrimitives.ReadUInt16BigEndian(fixedPart[60..62]); + + var assumeValid = (flags & 0x8000) != 0; + var extended = (flags & 0x4000) != 0; + var stage = (flags >> 12) & 0x3; + var nameLength = flags & 0xFFF; + + offset += EntryFixedLength; + + var skipWorktree = false; + var intentToAdd = false; + + if (extended) + { + if (version < 3) + { + throw new GitObjectStoreException("The index file is malformed: an extended entry appears in a version 2 index."); + } + + var extendedFlags = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + skipWorktree = (extendedFlags & 0x4000) != 0; + intentToAdd = (extendedFlags & 0x2000) != 0; + offset += 2; + } + + string path; + + if (version < 4) + { + var nameBytes = nameLength < 0xFFF + ? data.Slice(offset, nameLength) + : data[offset..(offset + data[offset..].IndexOf((byte)0))]; + path = Encoding.UTF8.GetString(nameBytes); + + // The entry is zero-padded so its total length is a multiple of eight, + // with at least one trailing NUL. + var entryLength = ((offset - entryStart) + nameBytes.Length + 8) & ~7; + offset = entryStart + entryLength; + } + else + { + // Version 4 prefix-compresses the path against the previous entry's path. + (var stripCount, offset) = ReadVariableWidthInt(data, offset); + var suffixEnd = data[offset..].IndexOf((byte)0); + var suffix = Encoding.UTF8.GetString(data.Slice(offset, suffixEnd)); + path = previousPath[..(previousPath.Length - stripCount)] + suffix; + offset += suffixEnd + 1; + } + + var entry = new GitIndexEntry( + path, + mode, + objectId, + stage, + size, + modificationSeconds, + modificationNanoseconds, + assumeValid, + skipWorktree, + intentToAdd); + + return (entry, offset); + } + + private static (int Value, int Offset) ReadVariableWidthInt(ReadOnlySpan data, int offset) + { + // Git's offset-encoded variable-width integer: each continuation adds one. + int current = data[offset++]; + var value = current & 0x7F; + + while ((current & 0x80) != 0) + { + value++; + current = data[offset++]; + value = (value << 7) | (current & 0x7F); + } + + return (value, offset); + } +} diff --git a/src/GitVersion.Git.Managed/Status/GitStatusCalculator.cs b/src/GitVersion.Git.Managed/Status/GitStatusCalculator.cs new file mode 100644 index 0000000000..16b23e37ef --- /dev/null +++ b/src/GitVersion.Git.Managed/Status/GitStatusCalculator.cs @@ -0,0 +1,360 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; + +namespace GitVersion.Git; + +/// +/// Computes the number of uncommitted changes in a working directory, matching the +/// semantics GitVersion has always used with libgit2: the number of distinct paths in +/// a diff of the HEAD tree against the index and the working directory combined — +/// untracked files included, ignored files excluded. +/// +internal sealed class GitStatusCalculator(GitRepositoryLayout layout, GitObjectStore objectStore) +{ + private readonly GitRepositoryLayout layout = layout ?? throw new ArgumentNullException(nameof(layout)); + private readonly GitTreeDiff treeDiff = new(objectStore); + private readonly Lazy ignoreCase = new(() => + GitConfigurationFile.Load(Path.Combine(layout.CommonDirectory, "config")) + .GetBoolean("core", null, "ignorecase") ?? false); + + /// + /// Counts the paths which differ between the given HEAD tree, the index, and the + /// working directory. + /// + /// The root tree of the HEAD commit. + /// The number of changed paths. + public int CountUncommittedChanges(GitObjectId headTreeId) + { + var workingDirectory = RequireWorkingDirectory(); + var indexPath = Path.Combine(this.layout.GitDirectory, "index"); + var index = GitIndex.Read(indexPath); + var indexTimestamp = ReadUnixTimestampSeconds(indexPath); + var headFiles = this.treeDiff.FlattenTree(headTreeId); + var indexEntries = index.Entries.ToDictionary(entry => entry.Path, StringComparer.Ordinal); + + var changed = new HashSet(StringComparer.Ordinal); + + CompareHeadWithIndex(headFiles, indexEntries, changed); + CompareIndexWithWorkingDirectory(workingDirectory, indexEntries, indexTimestamp, changed); + changed.UnionWith(FindUntrackedFiles(workingDirectory, indexEntries)); + + return changed.Count; + } + + /// + /// Counts the changes the way GitVersion counts them in a repository whose HEAD is + /// unborn (a brand-new repository): the untracked files. + /// + /// The number of changed paths. + public int CountChangesInEmptyRepository() + { + var workingDirectory = RequireWorkingDirectory(); + var index = GitIndex.Read(Path.Combine(this.layout.GitDirectory, "index")); + var indexEntries = index.Entries.ToDictionary(entry => entry.Path, StringComparer.Ordinal); + + return FindUntrackedFiles(workingDirectory, indexEntries).Count; + } + + private string RequireWorkingDirectory() => + this.layout.WorkingDirectory + ?? throw new GitObjectStoreException("The repository is bare: it has no working directory to compute uncommitted changes for."); + + private static void CompareHeadWithIndex( + Dictionary headFiles, + Dictionary indexEntries, + HashSet changed) + { + foreach (var (path, headEntry) in headFiles) + { + if (!indexEntries.TryGetValue(path, out var indexEntry)) + { + changed.Add(path); + continue; + } + + if (indexEntry.ObjectId != headEntry.Sha || indexEntry.Mode != Convert.ToUInt32(headEntry.Mode, 8)) + { + changed.Add(path); + } + } + + foreach (var path in indexEntries.Keys.Where(path => !headFiles.ContainsKey(path))) + { + changed.Add(path); + } + } + + private static void CompareIndexWithWorkingDirectory( + string workingDirectory, + Dictionary indexEntries, + long indexTimestamp, + HashSet changed) + { + foreach (var (path, entry) in indexEntries) + { + if (IsWorkingTreeEntryModified(workingDirectory, path, entry, indexTimestamp)) + { + changed.Add(path); + } + } + } + + private static bool IsWorkingTreeEntryModified( + string workingDirectory, + string path, + GitIndexEntry entry, + long indexTimestamp) + { + if (entry.Stage != 0) + { + // A conflicted path is always a change. + return true; + } + + if (entry.AssumeValid || entry.SkipWorktree) + { + return false; + } + + var filePath = Path.Combine(workingDirectory, path.Replace('/', Path.DirectorySeparatorChar)); + + if (entry.IsGitLink) + { + // A submodule: only its presence is checked, matching what the diff exposes. + return !Directory.Exists(filePath); + } + + if (entry.IsSymbolicLink) + { + // A symbolic link's blob content is the raw link target, not the target's content. + var linkTarget = new FileInfo(filePath).LinkTarget; + return linkTarget is null + || HashBlob(Encoding.UTF8.GetBytes(linkTarget.Replace(Path.DirectorySeparatorChar, '/'))) != entry.ObjectId; + } + + var fileInfo = new FileInfo(filePath); + + if (!fileInfo.Exists) + { + return true; + } + + if ((uint)fileInfo.Length != entry.Size || HasExecutableBitChanged(fileInfo, entry)) + { + return true; + } + + // Like git, trust the cached stat data: when size and modification time still match + // the index entry, the file is clean without hashing. An entry whose timestamp is not + // older than the index file itself is "racily clean" and must be verified by content. + var fileTimestamp = new DateTimeOffset(fileInfo.LastWriteTimeUtc).ToUnixTimeSeconds(); + var racilyClean = entry.ModificationTimeSeconds >= indexTimestamp; + + if (!racilyClean && fileTimestamp == entry.ModificationTimeSeconds) + { + return false; + } + + return !ContentMatchesIndexEntry(fileInfo, entry); + } + + /// + /// Verifies a file's content against the staged blob. When the raw content does not match, + /// the CRLF-normalized content is tried as well: git's check-in filters strip carriage + /// returns for text files, so a checkout with CRLF line endings still counts as clean — + /// matching how libgit2 applies filters before comparing blob ids. + /// + private static bool ContentMatchesIndexEntry(FileInfo fileInfo, GitIndexEntry entry) + { + var content = File.ReadAllBytes(fileInfo.FullName); + + if (HashBlob(content) == entry.ObjectId) + { + return true; + } + + var normalized = RemoveCarriageReturnsBeforeLineFeeds(content); + return normalized is not null && HashBlob(normalized) == entry.ObjectId; + } + + private static byte[]? RemoveCarriageReturnsBeforeLineFeeds(byte[] content) + { + var result = new byte[content.Length]; + var length = 0; + var removed = false; + + for (var i = 0; i < content.Length; i++) + { + if (content[i] == (byte)'\r' && i + 1 < content.Length && content[i + 1] == (byte)'\n') + { + removed = true; + continue; + } + + result[length++] = content[i]; + } + + return removed ? result[..length] : null; + } + + /// + /// Resolves the user's global excludes file the way git does: core.excludesFile + /// from the repository or global configuration, with the XDG default + /// ($XDG_CONFIG_HOME/git/ignore, usually ~/.config/git/ignore) as fallback. + /// + private string? ResolveGlobalExcludesFile() + { + static string? GetExcludesFile(string configPath) => + File.Exists(configPath) + ? GitConfigurationFile.Load(configPath).GetString("core", null, "excludesfile") + : null; + + var home = SysEnv.GetEnvironmentVariable("HOME") + ?? SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile); + var xdgConfigHome = SysEnv.GetEnvironmentVariable("XDG_CONFIG_HOME"); + var xdgBase = string.IsNullOrEmpty(xdgConfigHome) ? Path.Combine(home, ".config") : xdgConfigHome; + + // Later configuration levels override earlier ones, so the repository config wins. + var excludesFile = GetExcludesFile(Path.Combine(this.layout.CommonDirectory, "config")) + ?? GetExcludesFile(Path.Combine(home, ".gitconfig")) + ?? GetExcludesFile(Path.Combine(xdgBase, "git", "config")); + + if (excludesFile is null) + { + return Path.Combine(xdgBase, "git", "ignore"); + } + + return excludesFile.StartsWith("~/", StringComparison.Ordinal) + ? Path.Combine(home, excludesFile[2..]) + : excludesFile; + } + + private static long ReadUnixTimestampSeconds(string path) => + File.Exists(path) + ? new DateTimeOffset(File.GetLastWriteTimeUtc(path)).ToUnixTimeSeconds() + : 0; + + private List FindUntrackedFiles(string workingDirectory, Dictionary indexEntries) + { + var untracked = new List(); + var ignoreSources = new List<(string BasePrefix, GitIgnoreRules Rules)>(); + + // Shallowest sources first: the user's global excludes file, then the repository's + // info/exclude, then the per-directory .gitignore chain added while walking. + if (ResolveGlobalExcludesFile() is { } globalExcludesFile && GitIgnoreRules.Load(globalExcludesFile, this.ignoreCase.Value) is { } globalRules) + { + ignoreSources.Add(("", globalRules)); + } + + if (GitIgnoreRules.Load(Path.Combine(this.layout.CommonDirectory, "info", "exclude"), this.ignoreCase.Value) is { } excludeRules) + { + ignoreSources.Add(("", excludeRules)); + } + + WalkDirectory(workingDirectory, relativePrefix: "", this.ignoreCase.Value, ignoreSources, indexEntries, untracked); + return untracked; + } + + private static void WalkDirectory( + string directory, + string relativePrefix, + bool ignoreCase, + List<(string BasePrefix, GitIgnoreRules Rules)> ignoreSources, + Dictionary indexEntries, + List untracked) + { + var addedSource = false; + + if (GitIgnoreRules.Load(Path.Combine(directory, ".gitignore"), ignoreCase) is { } rules) + { + ignoreSources.Add((relativePrefix, rules)); + addedSource = true; + } + + try + { + foreach (var entry in new DirectoryInfo(directory).EnumerateFileSystemInfos().OrderBy(entry => entry.Name, StringComparer.Ordinal)) + { + var name = entry.Name; + + if (relativePrefix.Length == 0 && name == ".git") + { + continue; + } + + var relativePath = relativePrefix + name; + + // A symbolic link is a single file-like entry whose blob is the link target. + // It is never descended into, matching git — and preventing link loops. + var isDirectory = entry is DirectoryInfo && (entry.Attributes & FileAttributes.ReparsePoint) == 0; + + if (IsIgnored(ignoreSources, relativePath, isDirectory)) + { + // Ignored directories are not descended into: nothing below them + // can be re-included, matching git. + continue; + } + + if (isDirectory) + { + WalkDirectory(entry.FullName, relativePath + "/", ignoreCase, ignoreSources, indexEntries, untracked); + } + else if (!indexEntries.ContainsKey(relativePath)) + { + untracked.Add(relativePath); + } + } + } + finally + { + if (addedSource) + { + ignoreSources.RemoveAt(ignoreSources.Count - 1); + } + } + } + + private static bool IsIgnored( + List<(string BasePrefix, GitIgnoreRules Rules)> ignoreSources, + string relativePath, + bool isDirectory) + { + // Deeper sources take precedence over shallower ones; .git/info/exclude is the + // shallowest of all. + for (var i = ignoreSources.Count - 1; i >= 0; i--) + { + var (basePrefix, rules) = ignoreSources[i]; + + if (rules.IsIgnored(relativePath[basePrefix.Length..], isDirectory) is { } decision) + { + return decision; + } + } + + return false; + } + + private static bool HasExecutableBitChanged(FileInfo fileInfo, GitIndexEntry entry) + { + // Windows has no executable bit and git ignores file modes there. + if (OperatingSystem.IsWindows()) + { + return false; + } + + var isExecutable = (File.GetUnixFileMode(fileInfo.FullName) & UnixFileMode.UserExecute) != 0; + return isExecutable != entry.IsExecutable; + } + + [SuppressMessage("Critical Security Hotspot", "S4790:Using weak hashing algorithms is security-sensitive", Justification = "Git object ids are SHA-1 by definition; the hash is used for content identity, not security.")] + private static GitObjectId HashBlob(byte[] content) + { + var header = Encoding.ASCII.GetBytes($"blob {content.Length}\0"); + + var buffer = new byte[header.Length + content.Length]; + header.CopyTo(buffer, 0); + content.CopyTo(buffer, header.Length); + + return GitObjectId.Parse(SHA1.HashData(buffer)); + } +} diff --git a/src/GitVersion.Git.Managed/StreamExtensions.cs b/src/GitVersion.Git.Managed/StreamExtensions.cs new file mode 100644 index 0000000000..e0faabbbdb --- /dev/null +++ b/src/GitVersion.Git.Managed/StreamExtensions.cs @@ -0,0 +1,81 @@ +// Portions derived from Nerdbank.GitVersioning (https://github.com/dotnet/Nerdbank.GitVersioning), MIT License. + +using System.Buffers; + +namespace GitVersion.Git; + +/// +/// Provides extension methods for the class. +/// +internal static class StreamExtensions +{ + /// + /// Reads a 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. + /// Thrown when the encoded value does not fit in an . + public static int ReadMbsInt(this Stream stream) + { + var value = 0L; + var currentBit = 0; + + while (true) + { + var read = stream.ReadByte(); + if (read == -1) + { + throw new EndOfStreamException(); + } + + value |= (long)(read & 0b_0111_1111) << currentBit; + currentBit += 7; + + if (currentBit > 35 || value > int.MaxValue) + { + throw new GitObjectStoreException("The variable-length integer is malformed or too large."); + } + + if (read < 128) + { + break; + } + } + + return (int)value; + } + + /// + /// Reads the specified number of bytes from a stream, or until the end of the stream. + /// + /// The stream to read from. + /// The number of bytes to be read. + /// The stream to copy the read bytes to, if required. + /// The number of bytes actually read. This will be less than only if the end of is reached. + public static int ReadBytes(this Stream readFrom, int length, Stream? copyTo = null) + { + var bytesRemaining = length; + var buffer = ArrayPool.Shared.Rent(Math.Min(50 * 1024, bytesRemaining)); + try + { + while (bytesRemaining > 0) + { + var read = readFrom.Read(buffer, 0, Math.Min(buffer.Length, bytesRemaining)); + if (read == 0) + { + break; + } + + copyTo?.Write(buffer, 0, read); + bytesRemaining -= read; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return length - bytesRemaining; + } +} diff --git a/src/GitVersion.MsBuild.Tests/GitVersion.MsBuild.Tests.csproj b/src/GitVersion.MsBuild.Tests/GitVersion.MsBuild.Tests.csproj index 2aec02e042..c6073b2521 100644 --- a/src/GitVersion.MsBuild.Tests/GitVersion.MsBuild.Tests.csproj +++ b/src/GitVersion.MsBuild.Tests/GitVersion.MsBuild.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/GitVersion.slnx b/src/GitVersion.slnx index c763590e6b..9d1349ca55 100644 --- a/src/GitVersion.slnx +++ b/src/GitVersion.slnx @@ -8,6 +8,8 @@ + +