diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1f63884878..75a3a95753 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,6 +15,13 @@ "regitlint" ], "rollForward": false + }, + "dotnet-coverage": { + "version": "18.9.0", + "commands": [ + "dotnet-coverage" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.github/scripts/normalize-coverage-paths.ps1 b/.github/scripts/normalize-coverage-paths.ps1 new file mode 100644 index 0000000000..ced6e9c5a2 --- /dev/null +++ b/.github/scripts/normalize-coverage-paths.ps1 @@ -0,0 +1,193 @@ +param( + [Parameter(Mandatory = $true)][string]$CoverageFile, + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$ProjectRelativePath +) + +# Some test suites (e.g. GitProperties.Build.Test) run dotnet build/publish subprocesses against their own temporary +# copy of the repo, so the PDB (and therefore the coverage report) embeds an ephemeral temp path per test run instead +# of the real checkout path, and dotnet-coverage records one separate entry per subprocess rather than +# recognizing them as the same logical file. This script rewrites any path ending in $ProjectRelativePath to the real +# checkout path, then merges the resulting duplicate entries: line hits are summed, and each branch +# (always a binary jump, so its "coverage" is always 0%/50%/100%) takes the maximum observed across +# subprocesses. Without that merge, tools that correlate coverage by file path (SonarCloud) or re-aggregate reports +# (ReportGenerator's Cobertura export) both pick an arbitrary "last one wins" entry instead of the union of what +# every subprocess actually executed. +# +# Branch merging is a best-effort approximation: taking the max per condition cannot distinguish "two subprocesses +# both took the same half of a 50/50 branch" from "they took different halves", so it can still undercount versus a +# true union, but it never overstates coverage and is strictly more accurate than last-one-wins. +Add-Type -AssemblyName System.Xml.Linq + +$normalizedRelativePath = $ProjectRelativePath.Replace('\', '/').Trim('/') +$escapedSegments = $normalizedRelativePath -split '/' | ForEach-Object { [Regex]::Escape($_) } +$pattern = '[^"]*[\\/]' + ($escapedSegments -join '[\\/]') + '[\\/]' +$canonicalPrefix = ($RepoRoot.TrimEnd('/', '\').Replace('\', '/')) + '/' + $normalizedRelativePath +$replacement = "$canonicalPrefix/" + +$content = Get-Content -Path $CoverageFile -Raw +$content = [System.Text.RegularExpressions.Regex]::Replace($content, $pattern, $replacement) + +$doc = [System.Xml.Linq.XDocument]::Parse($content) +$invariantCulture = [System.Globalization.CultureInfo]::InvariantCulture + +function Format-Percentage([double]$value) { + $rounded = [Math]::Round($value, 2) + + if ($rounded -eq [Math]::Floor($rounded)) { + return "$([int]$rounded)%" + } + + return "$($rounded.ToString($invariantCulture))%" +} + +function Get-LineRate($lines) { + $lines = @($lines) + + if ($lines.Count -eq 0) { + return "0" + } + + $covered = @($lines | Where-Object { [int]$_.Attribute("hits").Value -gt 0 }).Count + return ($covered / $lines.Count).ToString($invariantCulture) +} + +function Get-BranchCounts($lines) { + $total = 0 + $covered = 0 + + foreach ($line in ($lines | Where-Object { $_.Attribute("branch").Value -eq "True" })) { + $conditionsElement = $line.Element("conditions") + + if ($null -eq $conditionsElement) { + continue + } + + foreach ($condition in $conditionsElement.Elements("condition")) { + $percentage = [double]($condition.Attribute("coverage").Value.TrimEnd('%')) + $total += 2 + $covered += [Math]::Round($percentage / 100 * 2) + } + } + + return [PSCustomObject]@{ Total = $total; Covered = $covered } +} + +function Get-BranchRate($lines) { + $counts = Get-BranchCounts $lines + + if ($counts.Total -eq 0) { + return "1" + } + + return ($counts.Covered / $counts.Total).ToString($invariantCulture) +} + +foreach ($package in $doc.Descendants("package")) { + $classesElement = $package.Element("classes") + + if ($null -eq $classesElement) { + continue + } + + $classGroups = $classesElement.Elements("class") | Group-Object { + $_.Attribute("name").Value + "|" + $_.Attribute("filename").Value + } + + foreach ($group in $classGroups) { + $keptClass = $group.Group[0] + + if ($group.Count -gt 1) { + $lineHits = @{} + $conditionCoverage = @{} + + foreach ($class in $group.Group) { + foreach ($line in $class.Element("lines").Elements("line")) { + $number = $line.Attribute("number").Value + $hits = [int]$line.Attribute("hits").Value + + if ($lineHits.ContainsKey($number)) { + $lineHits[$number] += $hits + } + else { + $lineHits[$number] = $hits + } + + $conditionsElement = $line.Element("conditions") + + if ($null -eq $conditionsElement) { + continue + } + + foreach ($condition in $conditionsElement.Elements("condition")) { + $key = "$number|$($condition.Attribute("number").Value)" + $percentage = [double]($condition.Attribute("coverage").Value.TrimEnd('%')) + + if (-not $conditionCoverage.ContainsKey($key) -or $percentage -gt $conditionCoverage[$key]) { + $conditionCoverage[$key] = $percentage + } + } + } + } + + foreach ($line in $keptClass.Descendants("line")) { + $line.SetAttributeValue("hits", [string]$lineHits[$line.Attribute("number").Value]) + + $conditionsElement = $line.Element("conditions") + + if ($null -eq $conditionsElement) { + continue + } + + $lineTotal = 0 + $lineCovered = 0 + + foreach ($condition in $conditionsElement.Elements("condition")) { + $key = "$($line.Attribute("number").Value)|$($condition.Attribute("number").Value)" + $percentage = $conditionCoverage[$key] + $condition.SetAttributeValue("coverage", (Format-Percentage $percentage)) + $lineTotal += 2 + $lineCovered += [Math]::Round($percentage / 100 * 2) + } + + $lineCoverageDescription = "$(Format-Percentage (($lineCovered / $lineTotal) * 100)) ($lineCovered/$lineTotal)" + $line.SetAttributeValue("condition-coverage", $lineCoverageDescription) + } + + foreach ($duplicate in $group.Group | Select-Object -Skip 1) { + $duplicate.Remove() + } + } + + $keptClassLines = $keptClass.Element("lines").Elements("line") + $keptClass.SetAttributeValue("line-rate", (Get-LineRate $keptClassLines)) + $keptClass.SetAttributeValue("branch-rate", (Get-BranchRate $keptClassLines)) + } + + $packageLines = $classesElement.Elements("class").Element("lines").Elements("line") + $package.SetAttributeValue("line-rate", (Get-LineRate $packageLines)) + $package.SetAttributeValue("branch-rate", (Get-BranchRate $packageLines)) +} + +# SetAttributeValue (create-or-update) is used instead of Attribute().Value to avoid null-refs: dotnet-coverage omits +# these attributes from the root element entirely (rather than writing zeros) when its profiler never initialized. +$allLines = @($doc.Descendants("class") | ForEach-Object { $_.Element("lines").Elements("line") }) +$doc.Root.SetAttributeValue("lines-valid", [string]$allLines.Count) +$doc.Root.SetAttributeValue("lines-covered", [string]@($allLines | Where-Object { [int]$_.Attribute("hits").Value -gt 0 }).Count) +$doc.Root.SetAttributeValue("line-rate", (Get-LineRate $allLines)) + +$branchCounts = Get-BranchCounts $allLines +$doc.Root.SetAttributeValue("branches-valid", [string]$branchCounts.Total) +$doc.Root.SetAttributeValue("branches-covered", [string]$branchCounts.Covered) +$doc.Root.SetAttributeValue("branch-rate", (Get-BranchRate $allLines)) + +# A profiler that never initializes (e.g. dynamic instrumentation isn't supported on macOS arm64 runners, and this +# suite's build/publish subprocesses can't be statically instrumented since their assemblies don't exist until +# mid-run) yields zero packages/classes here. That's a platform limitation, not proof the code is untested. +if ($allLines.Count -eq 0) { + $message = "Skipping $CoverageFile - no coverage data found" + Write-Warning $message + Write-Output "::warning::$message" +} + +$doc.Save($CoverageFile) diff --git a/.github/workflows/Steeltoe.All.yml b/.github/workflows/Steeltoe.All.yml index 6eeb9786db..c0b9d5cb50 100644 --- a/.github/workflows/Steeltoe.All.yml +++ b/.github/workflows/Steeltoe.All.yml @@ -23,7 +23,7 @@ env: SOLUTION_FILE: 'src/Steeltoe.All.slnx' COMMON_TEST_ARGS: >- --no-build --configuration Release --collect "XPlat Code Coverage" --logger trx --results-directory ${{ github.workspace }}/TestOutput - --settings coverlet.runsettings --blame-crash --blame-hang-timeout 1m + --settings coverlet.runsettings --blame-crash --blame-hang-timeout 5m jobs: build: @@ -99,6 +99,9 @@ jobs: docker cp src/Configuration/test/Encryption.Test/Cryptography/server.jks steeltoe-config:/workspace/server.jks docker restart steeltoe-config + - name: Restore tools + run: dotnet tool restore --verbosity minimal + - name: Restore packages run: dotnet restore ${{ env.SOLUTION_FILE }} /p:Configuration=Release /p:NuGetAudit=false --verbosity minimal @@ -107,14 +110,29 @@ jobs: - name: Test id: test - run: dotnet test ${{ env.SOLUTION_FILE }} --filter "${{ matrix.skipIntegrationTests == true && 'Category!=MemoryDumps&Category!=Integration' || 'Category!=MemoryDumps' }}" ${{ env.COMMON_TEST_ARGS }} + run: dotnet test ${{ env.SOLUTION_FILE }} --filter "${{ matrix.skipIntegrationTests == true && 'Category!=MemoryDumps&Category!=Integration&FullyQualifiedName!~Steeltoe.Management.GitProperties.Build.Test' || 'Category!=MemoryDumps&FullyQualifiedName!~Steeltoe.Management.GitProperties.Build.Test' }}" ${{ env.COMMON_TEST_ARGS }} - name: Test (memory dumps) id: test-memory-dumps run: dotnet test src/Management/test/Endpoint.Test --filter "Category=MemoryDumps" ${{ env.COMMON_TEST_ARGS }} + - name: Test (GitProperties.Build) + id: test-gitproperties-build + run: >- + dotnet dotnet-coverage collect -f cobertura -o ${{ github.workspace }}/TestOutput/GitProperties.Build.Test.cobertura.xml -- + dotnet test src/Management/test/GitProperties.Build.Test --no-build --configuration Release --filter "Category!=MemoryDumps" + --logger trx --results-directory ${{ github.workspace }}/TestOutput --blame-crash --blame-hang-timeout 5m + + - name: Normalize coverage paths (GitProperties.Build) + shell: pwsh + run: >- + ./.github/scripts/normalize-coverage-paths.ps1 + -CoverageFile ${{ github.workspace }}/TestOutput/GitProperties.Build.Test.cobertura.xml + -RepoRoot ${{ github.workspace }} + -ProjectRelativePath src/Management/src/GitProperties.Build + - name: Upload crash/hang dumps (on failure) - if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test-memory-dumps.outcome == 'failure') }} + if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test-memory-dumps.outcome == 'failure' || steps.test-gitproperties-build.outcome == 'failure') }} uses: actions/upload-artifact@v7 with: name: FailedTestOutput-${{ matrix.os }} @@ -124,7 +142,7 @@ jobs: if-no-files-found: ignore - name: Report test results - if: ${{ !cancelled() && (steps.test.outcome != 'skipped' || steps.test-memory-dumps.outcome != 'skipped') }} + if: ${{ !cancelled() && (steps.test.outcome != 'skipped' || steps.test-memory-dumps.outcome != 'skipped' || steps.test-gitproperties-build.outcome != 'skipped') }} uses: dorny/test-reporter@v3 with: name: ${{ matrix.os }} test results @@ -136,7 +154,7 @@ jobs: - name: Generate code coverage report uses: danielpalme/ReportGenerator-GitHub-Action@v5 with: - reports: '**/coverage.opencover.xml' + reports: '**/coverage.opencover.xml;**/*.cobertura.xml' targetdir: 'coveragereport' reporttypes: 'MarkdownAssembliesSummary;MarkdownSummaryGithub' filefilters: '-*.g.cs' diff --git a/.github/workflows/component-shared-workflow.yml b/.github/workflows/component-shared-workflow.yml index 3b2514ca58..cf73ca8a78 100644 --- a/.github/workflows/component-shared-workflow.yml +++ b/.github/workflows/component-shared-workflow.yml @@ -24,8 +24,8 @@ env: DOTNET_NOLOGO: true SOLUTION_FILE: 'src/Steeltoe.${{ inputs.component }}.slnf' COMMON_TEST_ARGS: >- - --no-build --configuration Release --collect "XPlat Code Coverage" --logger trx --results-directory ${{ github.workspace }}/TestOutput - --settings coverlet.runsettings --blame-crash --blame-hang-timeout 1m + --no-build --configuration Release --logger trx --results-directory ${{ github.workspace }}/TestOutput + --blame-crash --blame-hang-timeout 5m jobs: build: diff --git a/.github/workflows/sonarcube.yml b/.github/workflows/sonarcube.yml index cf7f52bc8c..9980e2be5b 100644 --- a/.github/workflows/sonarcube.yml +++ b/.github/workflows/sonarcube.yml @@ -82,6 +82,9 @@ jobs: docker cp src/Configuration/test/Encryption.Test/Cryptography/server.jks steeltoe-config:/workspace/server.jks docker restart steeltoe-config + - name: Restore tools + run: dotnet tool restore --verbosity minimal + - name: Begin Sonar .NET scanner id: sonar_begin env: @@ -89,6 +92,7 @@ jobs: run: >- dotnet sonarscanner begin /k:"SteeltoeOSS_steeltoe" /o:"steeltoeoss" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths=**/coverage.opencover.xml + /d:sonar.cs.cobertura.reportsPaths=**/*.cobertura.xml - name: Restore packages run: dotnet restore ${{ env.SOLUTION_FILE }} --verbosity minimal /p:Configuration=Release /p:NuGetAuditLevel=low /p:WarningsNotAsErrors='${{ env.NUGET_VULNERABLE_PACKAGE_WARNINGS }}' @@ -97,11 +101,24 @@ jobs: run: dotnet build ${{ env.SOLUTION_FILE }} --no-restore --configuration Release --verbosity minimal /p:NuGetAuditLevel=low /p:WarningsNotAsErrors='${{ env.NUGET_VULNERABLE_PACKAGE_WARNINGS }}' - name: Test - run: dotnet test ${{ env.SOLUTION_FILE }} --filter "Category!=MemoryDumps" ${{ env.SONAR_TEST_ARGS }} + run: dotnet test ${{ env.SOLUTION_FILE }} --filter "Category!=MemoryDumps&FullyQualifiedName!~Steeltoe.Management.GitProperties.Build.Test" ${{ env.SONAR_TEST_ARGS }} - name: Test (memory dumps) run: dotnet test src/Management/test/Endpoint.Test --filter "Category=MemoryDumps" ${{ env.SONAR_TEST_ARGS }} + - name: Test (GitProperties.Build) + run: >- + dotnet dotnet-coverage collect -f cobertura -o ${{ github.workspace }}/TestOutput/GitProperties.Build.Test.cobertura.xml -- + dotnet test src/Management/test/GitProperties.Build.Test --no-build --configuration Release --logger trx --results-directory ${{ github.workspace }}/TestOutput + + - name: Normalize coverage paths (GitProperties.Build) + shell: pwsh + run: >- + ./.github/scripts/normalize-coverage-paths.ps1 + -CoverageFile ${{ github.workspace }}/TestOutput/GitProperties.Build.Test.cobertura.xml + -RepoRoot ${{ github.workspace }} + -ProjectRelativePath src/Management/src/GitProperties.Build + - name: End Sonar .NET scanner if: ${{ !cancelled() && steps.sonar_begin.outcome == 'success' }} env: diff --git a/Directory.Build.targets b/Directory.Build.targets index 65ab7c7c73..08e335fa4a 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -15,6 +15,7 @@ PackageId.targets file which brings the ConfigurationSchema.json file into the Json Schema. --> + true $(MSBuildProjectDirectory)\ConfigurationSchema.json true @@ -39,15 +40,15 @@ - + $(TargetsTriggeredByCompilation);GenerateConfigurationSchema $(MSBuildThisFileDirectory)src\Tools\src\ConfigurationSchemaGenerator\ConfigurationSchemaGenerator.csproj - $(IntermediateOutputPath)$(AsemblyName).configschema.rsp + $(IntermediateOutputPath)$(AssemblyName).configschema.rsp $(IntermediateOutputPath)ConfigurationSchema.json - + - + + true + + + diff --git a/src/Management/src/Endpoint/Actuators/Info/Contributors/GitInfoContributor.cs b/src/Management/src/Endpoint/Actuators/Info/Contributors/GitInfoContributor.cs index 71e1a065de..8fcf3d4b75 100644 --- a/src/Management/src/Endpoint/Actuators/Info/Contributors/GitInfoContributor.cs +++ b/src/Management/src/Endpoint/Actuators/Info/Contributors/GitInfoContributor.cs @@ -19,7 +19,7 @@ internal sealed partial class GitInfoContributor : ConfigurationContributor, IIn private readonly ILogger _logger; public GitInfoContributor(ILogger logger) - : this($"{Directory.GetCurrentDirectory()}{Path.DirectorySeparatorChar}{GitPropertiesFileName}", logger) + : this(ResolveDefaultPropertiesPath(), logger) { } @@ -33,6 +33,30 @@ public GitInfoContributor(string propertiesPath, ILogger log _logger = logger; } + private static string ResolveDefaultPropertiesPath() + { + return ResolveDefaultPropertiesPath(AppContext.BaseDirectory, Directory.GetCurrentDirectory()); + } + + /// + /// Prefers the directory the running assembly was loaded from (where a build tool like Steeltoe.Management.GitProperties.Build copies git.properties to) + /// over the process's current working directory, since the latter depends entirely on how the application was launched (for example, `dotnet run` and + /// directly invoking a built DLL both leave the current directory pointed at the project directory, not the output directory the assembly - and + /// git.properties - actually live in) and can't be relied on to match. Takes both directories as parameters purely so tests can exercise this resolution + /// logic against isolated temporary directories, without touching either of this process's real ones. + /// + internal static string ResolveDefaultPropertiesPath(string baseDirectory, string currentDirectory) + { + string baseDirectoryPath = Path.Combine(baseDirectory, GitPropertiesFileName); + + if (File.Exists(baseDirectoryPath)) + { + return baseDirectoryPath; + } + + return Path.Combine(currentDirectory, GitPropertiesFileName); + } + public async Task ContributeAsync(InfoBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Management/src/GitProperties.Build/AtomicFile.cs b/src/Management/src/GitProperties.Build/AtomicFile.cs new file mode 100644 index 0000000000..f16748c16d --- /dev/null +++ b/src/Management/src/GitProperties.Build/AtomicFile.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Safely reads, writes, and locks files that multiple projects and target frameworks in a solution build may touch at the same time. +/// +/// +/// MSBuild builds multiple projects and target frameworks concurrently by default, so a reader can open a shared file mid-write, and two writers can +/// race to update it. Writes go through a swap that never leaves the file half-written, reads/writes retry briefly if they land on the wrong side of +/// someone else's swap, and an optional lock lets concurrent writers avoid redoing the same expensive work. +/// +internal static class AtomicFile +{ + private const int MaxAttempts = 10; + private static readonly TimeSpan ReadWriteRetryDelay = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan AcquireLockRetryDelay = TimeSpan.FromMilliseconds(50); + +#pragma warning disable S6354 // Use a testable date/time provider + // Justification: System.TimeProvider isn't available on netstandard2.0. + private static DateTime CurrentTimeUtc => DateTime.UtcNow; +#pragma warning restore S6354 // Use a testable date/time provider + + /// + /// Writes the given lines to . Even a concurrent reader only ever sees the complete previous content or the complete new + /// content, never a partial write. Retries briefly if another process is momentarily in the way. + /// + public static void Write(string path, List lines) + { + string? directory = Path.GetDirectoryName(path); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string tempPath = Path.Combine(directory ?? string.Empty, $"{Path.GetRandomFileName()}~"); + var encoding = new UTF8Encoding(false); + + ExecuteWithRetry(() => + { + File.WriteAllText(tempPath, $"{string.Join("\n", lines)}\n", encoding); + MoveOrReplace(tempPath, path); + }, ReadWriteRetryDelay, "write", path); + } + + /// + /// Reads all lines from , retrying briefly if another process is momentarily in the way. + /// + public static string[] Read(string path) + { + return ExecuteWithRetry(() => File.ReadAllLines(path), ReadWriteRetryDelay, "read", path); + } + + private static void MoveOrReplace(string sourcePath, string destinationPath) + { + try + { + File.Move(sourcePath, destinationPath); + } + catch (IOException) when (File.Exists(destinationPath)) + { + File.Replace(sourcePath, destinationPath, null); + } + } + + /// + /// Attempts to become the sole holder of for up to . Returns null if that doesn't + /// happen in time, or if locking isn't possible at all. Holding this lock is only ever an optimization, never a correctness requirement, so callers must + /// always have a safe fallback for when it can't be acquired. + /// + public static FileStream? TryAcquireExclusiveLock(string lockFilePath, TimeSpan timeout) + { + string? directory = Path.GetDirectoryName(lockFilePath); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + DateTime deadlineUtc = CurrentTimeUtc + timeout; + + return ExecuteWithTimeoutRetry(() => new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None), deadlineUtc, + AcquireLockRetryDelay); + } + + // These retry harnesses are only reachable when a real transient I/O error occurs mid-build, which tests can't reliably induce. + [ExcludeFromCodeCoverage] + private static void ExecuteWithRetry(Action action, TimeSpan retryDelay, string operation, string path) + { + ExecuteWithRetry(() => + { + action(); + return null; + }, retryDelay, operation, path); + } + + [ExcludeFromCodeCoverage] + private static T ExecuteWithRetry(Func action, TimeSpan retryDelay, string operation, string path) + { + Exception? lastError = null; + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + return action(); + } + catch (Exception exception) when (IsTransientError(exception)) + { + lastError = exception; + Thread.Sleep(retryDelay); + } + } + + throw new IOException($"Failed to {operation} {path} after {MaxAttempts} attempts.", lastError); + } + + [ExcludeFromCodeCoverage] + private static FileStream? ExecuteWithTimeoutRetry(Func action, DateTime deadlineUtc, TimeSpan retryDelay) + { + while (true) + { + try + { + return action(); + } + catch (Exception exception) when (IsTransientError(exception)) + { + if (CurrentTimeUtc >= deadlineUtc) + { + return null; + } + + Thread.Sleep(retryDelay); + } + } + } + + [ExcludeFromCodeCoverage] + private static bool IsTransientError(Exception exception) + { + return exception is IOException or UnauthorizedAccessException; + } +} diff --git a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs new file mode 100644 index 0000000000..178f9b5469 --- /dev/null +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Merges the shared git.properties cache with the fields that can never be cached: the live repository dirty state, the per-project $(Version), and the +/// build's own timestamp. Runs every build with no Inputs/Outputs skip: editing a tracked file doesn't touch any file timestamp this task could key +/// incrementality off, and a cached build time would go stale the moment it's reused. +/// +// ReSharper disable once UnusedType.Global +public sealed class ComposeGitPropertiesTask : Task +{ + /// + /// Gets or sets the resolved git repository root directory. + /// + [Required] + public string RepositoryRoot { get; set; } = string.Empty; + + /// + /// Gets or sets the git executable to invoke. + /// + [Required] + public string GitExecutable { get; set; } = string.Empty; + + /// + /// Gets or sets the shared cache file to read from. + /// + [Required] + public string CacheFile { get; set; } = string.Empty; + + /// + /// Gets or sets the per-project output file to write. + /// + [Required] + public string OutputFile { get; set; } = string.Empty; + + /// + /// Gets or sets the consuming project's $(Version), written as git.build.version. + /// + public string? Version { get; set; } + + /// + /// Gets or sets an optional additional path to copy the composed git.properties to. This is the durable fallback file used when a build has no usable + /// git repository at all (e.g. a source-based `cf push`, where .git is excluded from the pushed tree). + /// + public string? FallbackFile { get; set; } + + /// + /// Gets or sets a value indicating whether a git executable failure is reported as a warning (true) or an informational message (false). + /// + public bool EnableWarnings { get; set; } + + /// + public override bool Execute() + { + bool? isDirty = DetermineDirtyState(); + List lines = []; + + if (this.LogOnFailure($"failed to read {CacheFile}", () => lines = AtomicFile.Read(CacheFile).ToList())) + { + if (isDirty == true) + { + for (int index = 0; index < lines.Count; index++) + { + if (lines[index].StartsWith($"{GitPropertiesFormat.CommitIdDescribeKey}=", StringComparison.Ordinal)) + { + lines[index] += "-dirty"; + } + } + } + + if (isDirty != null) + { + lines.Add($"git.dirty={(isDirty.Value ? "true" : "false")}"); + } + + lines.Add($"git.build.version={GitPropertiesFormat.EscapeLineBreaks(Version)}"); + + // Local time, not UTC, to match the ISO-8601-with-offset style git itself uses for git.commit.time. This is "when this build ran, in the + // machine's own local time", not a value that needs to compare against the commit's own timestamp. +#pragma warning disable S6354 + string buildTime = DateTimeOffset.Now.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture); +#pragma warning restore S6354 + lines.Add($"git.build.time={buildTime}"); + + if (this.LogOnFailure($"failed to write {OutputFile}", () => AtomicFile.Write(OutputFile, lines))) + { + if (FallbackFile is null or "") + { + return true; + } + + return this.LogOnFailure($"failed to write fallback file {FallbackFile}", () => AtomicFile.Write(FallbackFile, lines)); + } + } + + return false; + } + + private bool? DetermineDirtyState() + { + const string dirtyCheckArguments = "status --porcelain"; + + int exitCode; + string stdout; + + try + { + exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, dirtyCheckArguments, out stdout, out _); + } + catch (Exception exception) + { + GitDiagnosticReporter.Report(Log, 7, EnableWarnings, + $"git.properties: unable to determine dirty state because '{GitExecutable}' failed ({exception.Message})."); + + return null; + } + + if (exitCode != 0) + { + GitDiagnosticReporter.Report(Log, 7, EnableWarnings, + $"git.properties: unable to determine dirty state because '{GitExecutable} {dirtyCheckArguments}' exited with code {exitCode}."); + + return null; + } + + return stdout.Length > 0; + } +} diff --git a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs new file mode 100644 index 0000000000..e9ac7f0462 --- /dev/null +++ b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Determines whether this project's own fully-resolved dependency graph includes any of . Used to auto-detect whether to +/// generate git.properties, so most projects in a large solution skip generation without opting out individually. +/// +/// +/// Reads (project.assets.json) rather than this project's own @(PackageReference) items: NuGet flattens the entire +/// transitive graph, through both PackageReference and ProjectReference chains, into every consuming project's assets file, so this also detects a +/// shared library wrapping actuator registration on behalf of many host apps. The assets file is written by a prior, separate restore pass, so if it +/// doesn't exist yet (a fresh clone with no restore), this safely reports no match instead of failing the build. +/// +// ReSharper disable once UnusedType.Global +public sealed class DetectConsumingPackageReferenceTask : Task +{ + /// + /// Gets or sets the semicolon-separated list of package IDs to look for. + /// + /// + /// Deliberately not [Required]: MSBuild's required-parameter check treats an empty string the same as "not supplied", which would turn an explicitly + /// blank value into a build error instead of the graceful "no package ID ever matches" outcome already produces. An + /// explicit blank value does reach this property in one case: a global property set on the command line (e.g. "-p:GitPropertiesConsumingPackageIds=") + /// can never be reassigned by the project's own conditional default, so it stays blank all the way through instead of falling back to the default + /// package ID. + /// + public string PackageIds { get; set; } = string.Empty; + + /// + /// Gets or sets the project's resolved assets file (typically $(ProjectAssetsFile)), or empty/nonexistent when the project has never been restored. + /// + public string ProjectAssetsFile { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether any of was found. + /// + [Output] + public bool HasReference { get; set; } + + /// + public override bool Execute() + { + if (ProjectAssetsFile.Length > 0 && File.Exists(ProjectAssetsFile)) + { + return this.LogOnFailure($"failed to read '{ProjectAssetsFile}' while checking for a consuming package reference", () => + { + string content = File.ReadAllText(ProjectAssetsFile); + HasReference = ContainsAnyPackage(content); + }); + } + + return true; + } + + private bool ContainsAnyPackage(string assetsFileContent) + { + foreach (string rawPackageId in PackageIds.Split(';')) + { + string packageId = rawPackageId.Trim(); + + // A plain substring search for efficiency. Taking a dependency on a JSON parser (so we can search inside "libraries") is too intrusive. + if (packageId.Length > 0 && assetsFileContent.IndexOf($"\"{packageId}/", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } +} diff --git a/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs new file mode 100644 index 0000000000..9bcb041a30 --- /dev/null +++ b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Walks up from looking for a ".git" directory. A ".git" file (worktree or submodule pointer) is deliberately reported +/// back via rather than treated as a match. +/// +// ReSharper disable once UnusedType.Global +public sealed class FindGitRepositoryRootTask : Task +{ + /// + /// Gets or sets the directory to start walking up from. + /// + [Required] + public string StartDirectory { get; set; } = string.Empty; + + /// + /// Gets or sets the resolved repository root directory, or empty when none was found. + /// + [Output] + public string RepositoryRoot { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether a ".git" file (rather than a directory) was found. + /// + [Output] + public bool IsUnsupportedGitFile { get; set; } + + /// + public override bool Execute() + { + string repositoryRoot = string.Empty; + bool isUnsupportedGitFile = false; + + return this.LogOnFailure($"failed to walk up from '{StartDirectory}' looking for a git repository root", () => + { + var current = new DirectoryInfo(StartDirectory); + + while (current != null) + { + string gitPath = Path.Combine(current.FullName, ".git"); + + if (Directory.Exists(gitPath)) + { + repositoryRoot = current.FullName; + + if (repositoryRoot.Length > 0 && repositoryRoot[repositoryRoot.Length - 1] != Path.DirectorySeparatorChar) + { + repositoryRoot = string.Concat(repositoryRoot, Path.DirectorySeparatorChar); + } + + break; + } + + if (File.Exists(gitPath)) + { + isUnsupportedGitFile = true; + break; + } + + current = current.Parent; + } + + RepositoryRoot = repositoryRoot; + IsUnsupportedGitFile = isUnsupportedGitFile; + }); + } +} diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs new file mode 100644 index 0000000000..2fec75701c --- /dev/null +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -0,0 +1,416 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Computes the git.properties fields that are stable across the whole repository and writes them to a shared cache file, so concurrent and/or +/// multi-targeted projects in the same solution build reuse it instead of each re-invoking git. +/// +// ReSharper disable once UnusedType.Global +public sealed class GenerateGitPropertiesCacheTask : Task +{ + private const string VersionCheckArguments = "--version"; + + private static readonly Version MinimumGitVersion = new(2, 15, 0); + + /// + /// How long to wait for the cross-process cache lock before generating the cache anyway. Sized to comfortably cover a real, if slow, cache generation + /// without blocking every other concurrently-building project/TFM too long if the holder is actually stuck rather than just slow. + /// + private static readonly TimeSpan CacheLockTimeout = TimeSpan.FromSeconds(10); + + private static readonly char[] CommitLogFieldSeparator = [(char)0x1F]; + + private static readonly char[] LineSeparators = + [ + '\r', + '\n' + ]; + + private static readonly string[] BranchEnvironmentVariableNames = + [ + "GITHUB_HEAD_REF", + "GITHUB_REF_NAME", + "BUILD_SOURCEBRANCHNAME", + "CI_COMMIT_REF_NAME", + "GIT_BRANCH", + "CIRCLE_BRANCH", + "TRAVIS_BRANCH" + ]; + + /// + /// Gets or sets the resolved git repository root directory. + /// + [Required] + public string RepositoryRoot { get; set; } = string.Empty; + + /// + /// Gets or sets the git executable to invoke. + /// + [Required] + public string GitExecutable { get; set; } = string.Empty; + + /// + /// Gets or sets the shared cache file to write. + /// + [Required] + public string CacheFile { get; set; } = string.Empty; + + /// + /// Gets or sets the length of the abbreviated commit ID to generate. + /// + [Required] + public string CommitIdAbbrevLength { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether a forgivable anomaly is reported as a warning (true) or an informational message (false). + /// + [Required] + public bool EnableWarnings { get; set; } + + /// + /// Gets or sets a value indicating whether to report when the shared cache is (re)generated. + /// + [Required] + public bool ReportFileWrites { get; set; } + + /// + public override bool Execute() + { + GitVersionStatus versionStatus = CheckGitVersion(); + + return versionStatus switch + { + GitVersionStatus.Unknown => false, + GitVersionStatus.Incompatible => true, + _ => this.LogOnFailure("an unexpected error occurred while generating the shared cache", () => + { + string? commitId = TryGetGitCommitId(); + + if (commitId == null) + { + return true; + } + + return TryGenerate(commitId); + }) + }; + } + + private GitVersionStatus CheckGitVersion() + { + string? output = GetGitVersion(); + + if (output != null) + { + Version? installedVersion = GitOutputParser.ParseGitVersion(output); + + if (installedVersion != null) + { + if (installedVersion >= MinimumGitVersion) + { + return GitVersionStatus.Compatible; + } + + GitDiagnosticReporter.Report(Log, 4, EnableWarnings, + $"git.properties generation skipped: installed git version {installedVersion} " + + $"is older than the minimum supported version ({MinimumGitVersion}). Upgrade git to resolve this."); + } + else + { + Log.LogError($"git.properties: could not parse the installed git version from '{GitExecutable} {VersionCheckArguments}' output: '{output}'."); + return GitVersionStatus.Unknown; + } + } + + return GitVersionStatus.Incompatible; + } + + private string? GetGitVersion() + { + string output; + int exitCode; + + try + { + exitCode = TryRunGit(VersionCheckArguments, out output, out _); + } + catch (Exception exception) + { + string message = $"git.properties generation skipped: could not run '{GitExecutable}' ({exception.Message})."; + GitDiagnosticReporter.Report(Log, 3, EnableWarnings, message); + return null; + } + + if (exitCode != 0) + { + string message = $"git.properties generation skipped: '{GitExecutable} {VersionCheckArguments}' exited with code {exitCode}."; + GitDiagnosticReporter.Report(Log, 3, EnableWarnings, message); + return null; + } + + return output; + } + + private string? TryGetGitCommitId() + { + int exitCode = TryRunGit("rev-parse --is-inside-work-tree", out string stdout, out _); + + if (exitCode != 0 || stdout != "true") + { + string message = $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git repository."; + GitDiagnosticReporter.Report(Log, 1, EnableWarnings, message); + return null; + } + + exitCode = TryRunGit("rev-parse HEAD", out stdout, out _); + + if (exitCode != 0) + { + GitDiagnosticReporter.Report(Log, 5, EnableWarnings, "git.properties generation skipped: repository has no commits yet."); + return null; + } + + return stdout; + } + + private bool TryGenerate(string commitId) + { + // Wrapped in a cross-process lock: MSBuild's own Inputs/Outputs staleness check runs independently per project/TFM, so multiple projects/TFMs + // building concurrently can all see the shared cache as stale and invoke this task at once. + // Only checks whether the file was rewritten by someone else while waiting for the lock. It doesn't check whether the existing content is still + // correct, which is the staleness check's job. Comparing the cache's stored commit ID instead would be wrong: tagging an existing commit invalidates + // the cache without changing the commit ID, so that check would silently skip writes that are actually needed. This is purely a "thundering herd" + // optimization, not a correctness fix. AtomicFile.Write already guarantees no reader ever observes a torn/partial file even without it. + + DateTime? cacheWriteTimeBeforeLock = File.Exists(CacheFile) ? File.GetLastWriteTimeUtc(CacheFile) : null; + + // Never deleted after use, only closed: a concurrent builder could recreate the same path a moment later, and + // "delete, then someone else recreates it" is the TOCTOU (Time-of-Check to Time-of-Use) race that breaks a file-based mutex. + using FileStream? cacheLock = AtomicFile.TryAcquireExclusiveLock($"{CacheFile}.lock", CacheLockTimeout); + + if (cacheLock == null) + { + Log.LogMessage("git.properties: could not acquire the lock for '{0}' within {1} seconds. Proceeding without it.", CacheFile, + CacheLockTimeout.TotalSeconds); + } + else if (WasCacheRewrittenWhileWaitingForLock(cacheWriteTimeBeforeLock)) + { + Log.LogMessage( + "git.properties: shared cache at '{0}' was rewritten by another concurrently-building project or target framework while waiting for " + + "the lock. Skipping.", CacheFile); + + return true; + } + + if (ReportFileWrites) + { + Log.LogMessage(MessageImportance.High, "git.properties: generating shared cache at '{0}'.", CacheFile); + } + + if (RunGit("rev-parse --is-shallow-repository", "determine shallow-clone status", out string stdout)) + { + bool isShallow = stdout == "true"; + + if (isShallow) + { + GitDiagnosticReporter.Report(Log, 6, EnableWarnings, + "git.properties: repository is a shallow clone. git.total.commit.count and git.closest.tag.commit.count will be left empty. Run " + + "'git fetch --unshallow' to fetch full history, or configure your CI checkout for full depth (e.g. GitHub Actions: fetch-depth: 0)."); + } + + CommitLogEntry? logEntry = GetLatestCommitLogEntry(); + + if (logEntry != null) + { + TagDescription tagDescription = DescribeClosestTag(isShallow); + TagsAndCommitCount? tagsAndCommitCount = ReadTagsAndTotalCommitCount(isShallow); + + if (tagsAndCommitCount != null) + { + GitConfig? config = ReadConfig(); + + if (config != null) + { + string branch = ResolveBranch(); + string buildHost = Environment.MachineName; + + List lines = + [ + $"git.branch={GitPropertiesFormat.EscapeLineBreaks(branch)}", + $"git.commit.id={GitPropertiesFormat.EscapeLineBreaks(commitId)}", + $"git.commit.id.abbrev={GitPropertiesFormat.EscapeLineBreaks(logEntry.AbbrevId)}", + $"{GitPropertiesFormat.CommitIdDescribeKey}={GitPropertiesFormat.EscapeLineBreaks(tagDescription.BaseDescribe)}", + $"git.commit.time={GitPropertiesFormat.EscapeLineBreaks(logEntry.CommitTime)}", + $"git.commit.message.short={GitPropertiesFormat.EscapeLineBreaks(logEntry.ShortMessage)}", + $"git.commit.message.full={GitPropertiesFormat.EscapeLineBreaks(logEntry.FullMessage)}", + $"git.commit.user.name={GitPropertiesFormat.EscapeLineBreaks(logEntry.AuthorName)}", + $"git.commit.user.email={GitPropertiesFormat.EscapeLineBreaks(logEntry.AuthorEmail)}", + $"git.build.host={GitPropertiesFormat.EscapeLineBreaks(buildHost)}", + $"git.build.user.name={GitPropertiesFormat.EscapeLineBreaks(config.UserName)}", + $"git.build.user.email={GitPropertiesFormat.EscapeLineBreaks(config.UserEmail)}", + $"git.tags={GitPropertiesFormat.EscapeLineBreaks(tagsAndCommitCount.Tags)}", + $"git.closest.tag.name={GitPropertiesFormat.EscapeLineBreaks(tagDescription.ClosestTagName)}", + $"git.closest.tag.commit.count={GitPropertiesFormat.EscapeLineBreaks(tagDescription.ClosestTagCommitCount)}", + $"git.remote.origin.url={GitPropertiesFormat.EscapeLineBreaks(config.RemoteUrl)}", + $"git.total.commit.count={GitPropertiesFormat.EscapeLineBreaks(tagsAndCommitCount.TotalCommitCount)}" + ]; + + return this.LogOnFailure($"failed to write {CacheFile}", () => + { + AtomicFile.Write(CacheFile, lines); + }); + } + } + } + } + + return false; + } + + private bool WasCacheRewrittenWhileWaitingForLock(DateTime? writeTimeBeforeLock) + { + if (File.Exists(CacheFile)) + { + return writeTimeBeforeLock == null || File.GetLastWriteTimeUtc(CacheFile) > writeTimeBeforeLock.Value; + } + + return false; + } + + private CommitLogEntry? GetLatestCommitLogEntry() + { + if (RunGit($"log -1 --abbrev={CommitIdAbbrevLength} --pretty=format:%h%x1f%an%x1f%ae%x1f%cI%x1f%s%x1f%B", "read commit metadata", out string stdout)) + { + string[] logFields = stdout.Split(CommitLogFieldSeparator, 6); + + return new CommitLogEntry(logFields.Length > 0 ? logFields[0] : string.Empty, logFields.Length > 1 ? logFields[1] : string.Empty, + logFields.Length > 2 ? logFields[2] : string.Empty, logFields.Length > 3 ? logFields[3] : string.Empty, + logFields.Length > 4 ? logFields[4] : string.Empty, logFields.Length > 5 ? logFields[5] : string.Empty); + } + + return null; + } + + private TagDescription DescribeClosestTag(bool isShallow) + { + int exitCode = TryRunGit("describe --tags --long --always", out string stdout, out _); + TagDescription description = exitCode == 0 ? GitOutputParser.ParseTagDescribe(stdout) : TagDescription.Empty; + + if (isShallow) + { + // Ancestry walk is truncated on a shallow clone, so a "count" here would be silently wrong. + description = new TagDescription(description.BaseDescribe, description.ClosestTagName, string.Empty); + } + + return description; + } + + private TagsAndCommitCount? ReadTagsAndTotalCommitCount(bool isShallow) + { + if (RunGit("tag --points-at HEAD", "list tags", out string stdout)) + { + string tags = string.Join(",", stdout.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries)); + + if (isShallow) + { + return new TagsAndCommitCount(tags, string.Empty); + } + + if (RunGit("rev-list --count HEAD", "count commits", out stdout)) + { + return new TagsAndCommitCount(tags, stdout); + } + } + + return null; + } + + private GitConfig? ReadConfig() + { + if (RunGit("config --list", "read git config", out string stdout)) + { + return GitOutputParser.ParseConfig(stdout); + } + + return null; + } + + private string ResolveBranch() + { + // Most CI checkouts leave HEAD detached, where git has no branch name to report at all. + string? branchName = BranchEnvironmentVariableNames.Select(Environment.GetEnvironmentVariable).FirstOrDefault(value => !string.IsNullOrEmpty(value)); + + if (branchName == null && RunGit("rev-parse --abbrev-ref HEAD", "determine branch name", out string stdout) && stdout != "HEAD") + { + branchName = stdout; + } + + return branchName ?? string.Empty; + } + + private bool RunGit(string arguments, string description, out string stdout) + { + int exitCode = TryRunGit(arguments, out stdout, out string stderr); + + if (exitCode == 0) + { + return true; + } + + Log.LogError("git.properties: failed to {0} (exit code {1}): {2}", description, exitCode, stderr); + return false; + } + + private int TryRunGit(string arguments, out string stdout, out string stderr) + { + return GitProcessRunner.Run(GitExecutable, RepositoryRoot, arguments, out stdout, out stderr); + } + + /// + /// Indicates whether the installed git version can be used. + /// + private enum GitVersionStatus + { + /// + /// Git ran and satisfies the minimum required version constraint. It is safe to proceed. + /// + Compatible, + + /// + /// Git couldn't be run at all or is older than the minimum required version. Both are forgivable, so generation simply skips. + /// + Incompatible, + + /// + /// Failed to parse output from "git --version". This isn't a routine, anticipated condition like the other two, so there's no safe fallback. This stops + /// the build instead of letting a stale cache get used regardless. + /// + Unknown + } + + private sealed class CommitLogEntry(string abbrevId, string authorName, string authorEmail, string commitTime, string shortMessage, string fullMessage) + { + public string AbbrevId { get; } = abbrevId; + public string AuthorName { get; } = authorName; + public string AuthorEmail { get; } = authorEmail; + public string CommitTime { get; } = commitTime; + public string ShortMessage { get; } = shortMessage; + public string FullMessage { get; } = fullMessage; + } + + private sealed class TagsAndCommitCount(string tags, string totalCommitCount) + { + public string Tags { get; } = tags; + public string TotalCommitCount { get; } = totalCommitCount; + } +} diff --git a/src/Management/src/GitProperties.Build/GitConfig.cs b/src/Management/src/GitProperties.Build/GitConfig.cs new file mode 100644 index 0000000000..792a57abe8 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitConfig.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build; + +internal sealed class GitConfig(string userName, string userEmail, string remoteUrl) +{ + public string UserName { get; } = userName; + public string UserEmail { get; } = userEmail; + public string RemoteUrl { get; } = remoteUrl; +} diff --git a/src/Management/src/GitProperties.Build/GitDiagnosticReporter.cs b/src/Management/src/GitProperties.Build/GitDiagnosticReporter.cs new file mode 100644 index 0000000000..d21f5ed1f6 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitDiagnosticReporter.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Steeltoe.Management.GitProperties.Build; + +internal static class GitDiagnosticReporter +{ + private const string DiagnosticPrefix = "GITPROPS"; + + public static void Report(TaskLoggingHelper log, int diagnosticId, bool enableWarnings, string message) + { + string code = $"{DiagnosticPrefix}{diagnosticId:D3}"; + + if (enableWarnings) + { + log.LogWarning(null, code, null, null, 0, 0, 0, 0, message); + } + else + { + log.LogMessage(null, code, null, null, 0, 0, 0, 0, MessageImportance.High, message); + } + } +} diff --git a/src/Management/src/GitProperties.Build/GitOutputParser.cs b/src/Management/src/GitProperties.Build/GitOutputParser.cs new file mode 100644 index 0000000000..d6afd90101 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitOutputParser.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Steeltoe.Management.GitProperties.Build; + +internal static class GitOutputParser +{ + /// + /// Matches "git version 2.42.0", "git version 2.42.0.windows.1", and "git version 2.39.5 (Apple Git-154)" alike, capturing only the leading + /// major/minor/patch numbers every real git build's "--version" output starts with, regardless of whatever vendor-specific suffix follows. + /// + private static readonly Regex GitVersionRegex = new(@"^git version (\d+)\.(\d+)(?:\.(\d+))?", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + /// + /// Parses the leading major/minor/patch numbers out of "git --version" output. + /// + public static Version? ParseGitVersion(string output) + { + Match match = GitVersionRegex.Match(output); + + if (!match.Success) + { + return null; + } + + int major = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + int minor = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + // Git versions before 2006 did not always include the patch version. And even today, versions built from source can look like "2.44-rc0". + int build = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0; + + return new Version(major, minor, build); + } + + /// + /// Parses "git describe --tags --long --always" output for its three possible shapes: exactly-on-tag ("tag-0-gsha"), N-commits-ahead ("tag-N-gsha"), and + /// no-tags-at-all (a bare "--always" fallback SHA, with no dashes). An empty or unrecognized shape yields all-empty fields, same as "no tags exist". + /// + public static TagDescription ParseTagDescribe(string describeOutput) + { + string baseDescribe = string.Empty; + string closestTagName = string.Empty; + string closestTagCommitCount = string.Empty; + + if (!string.IsNullOrEmpty(describeOutput)) + { + int lastDashIndex = describeOutput.LastIndexOf('-'); + int secondLastDash = lastDashIndex >= 0 ? describeOutput.LastIndexOf('-', lastDashIndex - 1) : -1; + + bool hasTagPrefix = lastDashIndex >= 0 && secondLastDash >= 0 && + describeOutput.Substring(lastDashIndex + 1).StartsWith("g", StringComparison.Ordinal); + + if (!hasTagPrefix) + { + // No tags reachable at all. The "--always" fallback is a bare abbreviated SHA. + baseDescribe = describeOutput; + } + else + { + closestTagName = describeOutput.Substring(0, secondLastDash); + closestTagCommitCount = describeOutput.Substring(secondLastDash + 1, lastDashIndex - secondLastDash - 1); + baseDescribe = closestTagCommitCount == "0" ? closestTagName : $"{closestTagName}-{closestTagCommitCount}"; + } + } + + return new TagDescription(baseDescribe, closestTagName, closestTagCommitCount); + } + + /// + /// Parses "git config --list" output for the keys we care about, stripping any embedded credentials from the remote URL. + /// + public static GitConfig ParseConfig(string configListOutput) + { + string userName = string.Empty; + string userEmail = string.Empty; + string remoteUrl = string.Empty; + + foreach (string line in configListOutput.Split('\n')) + { + int equalsIndex = line.IndexOf('='); + + if (equalsIndex >= 0) + { + string key = line.Substring(0, equalsIndex).Trim(); + string value = line.Substring(equalsIndex + 1).Trim(); + + if (string.Equals(key, "user.name", StringComparison.OrdinalIgnoreCase)) + { + userName = value; + } + else if (string.Equals(key, "user.email", StringComparison.OrdinalIgnoreCase)) + { + userEmail = value; + } + else if (string.Equals(key, "remote.origin.url", StringComparison.OrdinalIgnoreCase)) + { + remoteUrl = value; + } + } + } + + string safeRemoteUrl = StripUserInfo(remoteUrl); + return new GitConfig(userName, userEmail, safeRemoteUrl); + } + + private static string StripUserInfo(string url) + { + if (!string.IsNullOrEmpty(url)) + { + try + { + var uri = new Uri(url); + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var builder = new UriBuilder(uri) + { + UserName = string.Empty, + Password = string.Empty + }; + + return builder.Uri.ToString(); + } + } + catch (UriFormatException) + { + // Not a parseable absolute URL (e.g. SCP-like "git@host:org/repo.git"), so there is nothing to strip. + } + } + + return url; + } +} diff --git a/src/Management/src/GitProperties.Build/GitProcessRunner.cs b/src/Management/src/GitProperties.Build/GitProcessRunner.cs new file mode 100644 index 0000000000..e7912d8484 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitProcessRunner.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text; + +namespace Steeltoe.Management.GitProperties.Build; + +internal static class GitProcessRunner +{ + public static int Run(string gitExecutable, string repositoryRoot, string arguments, out string stdout, out string stderr) + { + var stdoutBuilder = new StringBuilder(); + var stderrBuilder = new StringBuilder(); + + var startInfo = new ProcessStartInfo + { + FileName = gitExecutable, + Arguments = arguments, + WorkingDirectory = repositoryRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + using var process = new Process(); + process.StartInfo = startInfo; + process.OutputDataReceived += (_, eventArgs) => AppendLine(stdoutBuilder, eventArgs.Data); + process.ErrorDataReceived += (_, eventArgs) => AppendLine(stderrBuilder, eventArgs.Data); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + stdout = stdoutBuilder.ToString().Trim(); + stderr = stderrBuilder.ToString().Trim(); + return process.ExitCode; + } + + private static void AppendLine(StringBuilder builder, string? line) + { + if (line == null) + { + return; + } + + // git itself always writes \n line endings on its own output (even on Windows). + builder.Append(line).Append('\n'); + } +} diff --git a/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs b/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs new file mode 100644 index 0000000000..1b0b63fd3c --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Rules specific to the content/shape of a git.properties file. +/// +internal static class GitPropertiesFormat +{ + public const string CommitIdDescribeKey = "git.commit.id.describe"; + + /// + /// Collapses line breaks to a literal "\n" so a value can never span multiple physical lines. GitInfoContributor can't handle multiline values. + /// + public static string EscapeLineBreaks(string? value) + { + return value is null or "" ? string.Empty : value.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\\n"); + } +} diff --git a/src/Management/src/GitProperties.Build/PackageReadme.md b/src/Management/src/GitProperties.Build/PackageReadme.md new file mode 100644 index 0000000000..42dbc08c20 --- /dev/null +++ b/src/Management/src/GitProperties.Build/PackageReadme.md @@ -0,0 +1,108 @@ +# Steeltoe.Management.GitProperties.Build + +Generates a `git.properties` file at build time, compatible with the [Spring Boot Actuator `git.properties`](https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints.info.git-commit-information) format. When used together with Steeltoe's `Info` actuator endpoint, the information in this file (commit ID, branch, tags, whether the repository was "dirty" at build time, etc.) is automatically exposed at runtime. + +## Getting started + +```console +dotnet add package Steeltoe.Management.GitProperties.Build +``` + +No other setup is required for a project that references `Steeltoe.Management.Endpoint` and lives inside a Git repository. The next time you build that project, a `git.properties` file is generated and copied into your build (and publish) output automatically. Steeltoe's `Info` actuator endpoint then picks it up automatically at runtime. + +## Example output + +A generated `git.properties` file looks like this: + +```properties +git.branch=main +git.commit.id=1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b +git.commit.id.abbrev=1a2b3c4 +git.commit.id.describe=v1.4.0-3-g1a2b3c4 +git.commit.time=2026-06-18T09:42:11+00:00 +git.commit.message.short=Fix null reference in health check +git.commit.message.full=Fix null reference in health check\nAdds a null check before calling Ping(). +git.commit.user.name=Jane Doe +git.commit.user.email=jane.doe@example.com +git.build.host=build-agent-03 +git.build.user.name=Jane Doe +git.build.user.email=jane.doe@example.com +git.tags= +git.closest.tag.name=v1.4.0 +git.closest.tag.commit.count=3 +git.remote.origin.url=https://github.com/example-org/example-app.git +git.total.commit.count=482 +git.dirty=false +git.build.version=1.4.0 +git.build.time=2026-07-09T14:32:10-06:00 +``` + +When Steeltoe's `Info` actuator endpoint is enabled, all of these values are automatically surfaced under the `git` key of that endpoint's response. You don't need to read this file yourself. + +## Configuration + +All settings are optional MSBuild properties, set in your project file (or a `Directory.Build.props` file): + +| Property | Default | Description | +|---|---|---| +| `GenerateGitProperties` | `auto` | Generates only when the project has a direct or indirect reference to one of `GitPropertiesConsumingPackageIds`. Set explicitly to `true` or `false` to always generate or always skip. | +| `GitPropertiesWriteToProjectDirectory` | `false` | Also writes a durable copy of `git.properties` directly next to your project file, so a remote build with no Git repository available can still find it. | +| `GitPropertiesEnableWarnings` | `true` | Whether the situations listed under [Diagnostics](#diagnostics) are reported as MSBuild warnings. | +| `GitPropertiesReportFileWrites` | `true` | Whether to report when the shared cache, `git.properties`, and its fallback copy are (re)written. Set to `false` to silence these. | +| `GitPropertiesConsumingPackageIds` | `Steeltoe.Management.Endpoint` | Semicolon-separated package IDs that trigger the `auto` default above. | +| `GitExecutable` | `git` | The git executable to invoke. Override this if `git` isn't on the `PATH` in your build environment. | +| `GitCommitIdAbbrevLength` | `7` | Number of characters used for the abbreviated commit ID. | + +## Diagnostics + +This package may log one of the following codes: + +| Code | Meaning | +|---|---| +| `GITPROPS001` | No usable Git repository was found. Either there is no `.git` directory anywhere above the project, or one exists but Git does not recognize it as a valid repository. | +| `GITPROPS002` | A `.git` *file* was found instead of a `.git` *directory*. This is how Git represents worktrees and submodules, which this package doesn't support. | +| `GITPROPS003` | The configured Git executable (see `GitExecutable`) could not be run. It may not be installed, or not on the `PATH`. | +| `GITPROPS004` | The installed Git version is older than 2.15.0, the minimum version this package requires. | +| `GITPROPS005` | A Git repository was found, but it has no commits yet. | +| `GITPROPS006` | The repository is a shallow clone, so `git.total.commit.count` and `git.closest.tag.commit.count` are left empty. | +| `GITPROPS007` | The repository's dirty state could not be determined, so `git.dirty` is omitted. | + +## Deploying without access to your Git repository + +By default, `git.properties` is generated using live information read directly from your local `.git` directory. It only ends up in your build or publish output directory. This works well when the system that builds or publishes your application also has access to that same `.git` directory. + +Some deployment methods don't give the build step access to your `.git` directory at all. For example, pushing your application's source code straight to Cloud Foundry (`cf push`) does not include your `.git` directory, so no `git.properties` can be produced. + +To work around this, run the following command locally before every push. Your `.git` directory must be available when you run it: + +```shell +dotnet build -t:WriteGitPropertiesFallbackFile +``` + +This command writes an extra copy of `git.properties` directly next to your project file without running a full build. + +> [!IMPORTANT] +> **You must add `git.properties` to your `.gitignore` file.** This file is a generated build artifact, not source code. It changes on every single build. If it isn't ignored, Git will consider your working directory to have uncommitted changes after every build, even when you haven't changed anything yourself. +> +> Add the following line to your `.gitignore` file: +> +> ```gitignore +> git.properties +> ``` +> +> This isn't just a tidiness recommendation. If you skip it, the `git.dirty` value inside the generated `git.properties` file will start reporting `true` on every build from then on. That happens because Git genuinely does see an uncommitted change: the file that keeps getting regenerated. This defeats the purpose of `git.dirty`, which is meant to tell you whether *your own* changes were committed, not whether this generated file was rewritten. +> +> If you deploy by pushing your source code directly, rather than a pre-built or published output (for example with Cloud Foundry's `cf push`), be careful not to *also* exclude `git.properties` from whatever gets pushed or deployed. For Cloud Foundry, that means leaving it out of `.cfignore`. `git.properties` must stay out of Git through `.gitignore`, but it still needs to be present on disk and travel along with your source code. + +## Good to know + +- **Git v2.15.0 or later must be installed.** The `git` command must be runnable during your build, either on the `PATH` or at a location you configure with `GitExecutable`. +- **Cross-platform.** Works the same way on Windows, Linux, and macOS. +- **Skips cleanly for anticipated Git issues.** If a Git repository can't be found or read for one of the reasons listed in [Diagnostics](#diagnostics), generation is skipped with a message you can suppress (see `GitPropertiesEnableWarnings`), instead of failing your build. This makes it safe to add this package to projects that aren't always built inside a Git checkout, such as a Docker image build stage. +- **Git worktrees and submodules aren't supported** (`GITPROPS002`). If your build runs from one, for example a coding agent working in its own worktree alongside your primary checkout, generation is skipped gracefully instead of failing. +- **Shallow clones are supported.** `git.total.commit.count` and `git.closest.tag.commit.count` are left empty, because a shallow clone doesn't have the full commit history needed to count them. This is reported via `GITPROPS006` (see [Diagnostics](#diagnostics)), so it's never silently incomplete. +- **Efficient in larger solutions.** The repository-wide information, which can be expensive to compute, is calculated at most once per build. It is shared across every project and target framework that references this package, instead of being recomputed for each one. +- **Performance impact.** This package executes real `git` commands, which has a small but real cost. Adding it to every project in a large solution is not recommended. Setting `GenerateGitProperties` to `true` unconditionally, so it always runs, is not recommended either. Add this package only to the projects that actually need `git.properties`, typically your actuator-hosting host apps. +- **Build-time only.** This package doesn't add any runtime dependency to your application. It never flows transitively to anything that references your project. +- **Found automatically at runtime, however your app is launched.** Steeltoe's `Info` actuator endpoint looks for `git.properties` next to your application's own assembly first. If it isn't there, it falls back to the current working directory. +- **An IDE build might not notice a new commit, branch, or tag.** Visual Studio and similar IDEs can skip invoking a real build for a project when none of the files they track (source code, project file, references) have changed, even if you've committed, switched branches, or created a tag since the last build. If `git.properties` looks out of date, force a full rebuild, or build from the command line with `dotnet build`. diff --git a/src/Management/src/GitProperties.Build/ReportGitDiagnosticTask.cs b/src/Management/src/GitProperties.Build/ReportGitDiagnosticTask.cs new file mode 100644 index 0000000000..a2982ef78e --- /dev/null +++ b/src/Management/src/GitProperties.Build/ReportGitDiagnosticTask.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +namespace Steeltoe.Management.GitProperties.Build; + +// ReSharper disable once UnusedType.Global +public sealed class ReportGitDiagnosticTask : Task +{ + /// + /// Gets or sets the numeric part of the diagnostic code to report. + /// + [Required] + public int DiagnosticId { get; set; } + + /// + /// Gets or sets a value indicating whether to report at warning level, rather than as a code-carrying informational message. + /// + public bool EnableWarnings { get; set; } + + /// + /// Gets or sets the diagnostic's body text. + /// + [Required] + public string Message { get; set; } = string.Empty; + + /// + public override bool Execute() + { + // Using the built-in MSBuild task provides no way to set the Code property. + GitDiagnosticReporter.Report(Log, DiagnosticId, EnableWarnings, Message); + return true; + } +} diff --git a/src/Management/src/GitProperties.Build/SourceCheckout.txt b/src/Management/src/GitProperties.Build/SourceCheckout.txt new file mode 100644 index 0000000000..19ece332cf --- /dev/null +++ b/src/Management/src/GitProperties.Build/SourceCheckout.txt @@ -0,0 +1,8 @@ +This file exists so the accompanying .targets file can detect, at MSBuild evaluation time, whether it is being +loaded straight from this source checkout (a consumer's own ProjectReference dev loop) or from an installed +NuGet package. This distinction matters because it determines whether tasks load in-process or out-of-process. + +This file is deliberately excluded from the packed .nupkg, so its absence at the equivalent location inside an +installed package is exactly what identifies "packaged" consumption. + +Do not delete, rename, or pack this file. diff --git a/src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj b/src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj new file mode 100644 index 0000000000..cc62a0ad60 --- /dev/null +++ b/src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj @@ -0,0 +1,51 @@ + + + + netstandard2.0 + Generates a Spring Boot-compatible git.properties file at build time via MSBuild props/targets/tasks. + git.properties;actuators;CloudFoundry;Tanzu + true + false + false + + + + + + + + + + + + false + bin\tasks\$(TargetFramework)\ + + + + + true + true + + false + + $(NoWarn);NU5128;NU5100 + + + + + + + + + + + + + diff --git a/src/Management/src/GitProperties.Build/TagDescription.cs b/src/Management/src/GitProperties.Build/TagDescription.cs new file mode 100644 index 0000000000..39baf83e93 --- /dev/null +++ b/src/Management/src/GitProperties.Build/TagDescription.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build; + +internal sealed class TagDescription(string baseDescribe, string closestTagName, string closestTagCommitCount) +{ + public static TagDescription Empty { get; } = new(string.Empty, string.Empty, string.Empty); + + public string BaseDescribe { get; } = baseDescribe; + public string ClosestTagName { get; } = closestTagName; + public string ClosestTagCommitCount { get; } = closestTagCommitCount; +} diff --git a/src/Management/src/GitProperties.Build/TaskExtensions.cs b/src/Management/src/GitProperties.Build/TaskExtensions.cs new file mode 100644 index 0000000000..4957773d84 --- /dev/null +++ b/src/Management/src/GitProperties.Build/TaskExtensions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Utilities; + +namespace Steeltoe.Management.GitProperties.Build; + +internal static class TaskExtensions +{ + // Only reachable when a real, unexpected I/O error occurs mid-build, which tests can't reliably induce. + [ExcludeFromCodeCoverage] + public static bool LogOnFailure(this Task task, string errorMessage, Action action) + { + return LogOnFailure(task, errorMessage, () => + { + action(); + return true; + }); + } + + // Only reachable when a real, unexpected I/O error occurs mid-build, which tests can't reliably induce. + [ExcludeFromCodeCoverage] + public static bool LogOnFailure(this Task task, string errorMessage, Func action) + { + try + { + return action(); + } + catch (Exception exception) + { + task.Log.LogError($"git.properties: {errorMessage}:{Environment.NewLine}{exception}"); + return false; + } + } +} diff --git a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets new file mode 100644 index 0000000000..bb1ddf8f73 --- /dev/null +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -0,0 +1,181 @@ + + + + auto + false + $(MSBuildProjectDirectory)\git.properties + true + true + Steeltoe.Management.Endpoint + git + 7 + + + + + + + + + + + + netstandard2.0 + $(MSBuildThisFileDirectory)..\bin\tasks\$(GitPropertiesTasksTfm)\Steeltoe.Management.GitProperties.Build.dll + true + false + + + + + + + + + + + + + + + + + + $(IntermediateOutputPath)git.properties + + + + <_GitPropertiesShouldGenerate>$(GenerateGitProperties) + + + + + + + + + + + + + + + $(GitRepositoryRoot)obj\_GitProperties\ + $(GitPropertiesCacheDirectory)git.properties.cache + + + + + + + + + + + <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\HEAD" Condition="Exists('$(GitRepositoryRoot).git\HEAD')" /> + <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\config" Condition="Exists('$(GitRepositoryRoot).git\config')" /> + <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\packed-refs" Condition="Exists('$(GitRepositoryRoot).git\packed-refs')" /> + <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\refs\heads\**\*" /> + <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\refs\tags\**\*" /> + + + + + + + + + + + + + + + + + + + + + <_GitPropertiesIncluded>true + + + + + + + <_GitPropertiesFallbackFileTarget Condition="'$(GitPropertiesWriteToProjectDirectory)' == 'true'">$(GitPropertiesFallbackFile) + + + + + + + + + <_GitPropertiesComposed>true + + + + + + + + + diff --git a/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs b/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs index c6012013d1..31951c6930 100644 --- a/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Steeltoe.Common.TestResources; +using Steeltoe.Common.TestResources.IO; using Steeltoe.Management.Endpoint.Actuators.Info; using Steeltoe.Management.Endpoint.Actuators.Info.Contributors; @@ -12,6 +13,44 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.Info.Contributors; public sealed class GitInfoContributorTest { + [Fact] + public void Default_path_prefers_base_directory_over_current_directory() + { + using var baseDirectory = new Sandbox(); + using var currentDirectory = new Sandbox(); + + string baseDirectoryFile = baseDirectory.CreateFile("git.properties", "git.commit.id=from-base-directory"); + currentDirectory.CreateFile("git.properties", "git.commit.id=from-current-directory"); + + string resolvedPath = GitInfoContributor.ResolveDefaultPropertiesPath(baseDirectory.FullPath, currentDirectory.FullPath); + + resolvedPath.Should().Be(baseDirectoryFile); + } + + [Fact] + public void Default_path_falls_back_to_current_directory_when_not_found_in_base_directory() + { + using var baseDirectory = new Sandbox(); + using var currentDirectory = new Sandbox(); + + string currentDirectoryFile = currentDirectory.CreateFile("git.properties", "git.commit.id=from-current-directory"); + + string resolvedPath = GitInfoContributor.ResolveDefaultPropertiesPath(baseDirectory.FullPath, currentDirectory.FullPath); + + resolvedPath.Should().Be(currentDirectoryFile); + } + + [Fact] + public void Default_path_falls_back_to_current_directory_when_not_found_anywhere() + { + using var baseDirectory = new Sandbox(); + using var currentDirectory = new Sandbox(); + + string resolvedPath = GitInfoContributor.ResolveDefaultPropertiesPath(baseDirectory.FullPath, currentDirectory.FullPath); + + resolvedPath.Should().Be(Path.Combine(currentDirectory.FullPath, "git.properties")); + } + [Fact] public async Task Logs_warning_when_git_properties_file_not_found() { @@ -51,6 +90,39 @@ public async Task Can_read_empty_git_properties_file() loggerProvider.GetAsText().Should().BeEmpty(); } + [Fact] + public async Task Multi_line_commit_message_keeps_the_escaped_literal_backslash_n() + { + using var directory = new Sandbox(); + + string path = directory.CreateFile("git.properties", """ + git.commit.message.short=Fix null reference in health check + git.commit.message.full=Fix null reference in health check\n\nAdds a null check before calling Ping(). + """); + + using var loggerFactory = new LoggerFactory(); + var contributor = new GitInfoContributor(path, loggerFactory.CreateLogger()); + var infoBuilder = new InfoBuilder(); + + await contributor.ContributeAsync(infoBuilder, TestContext.Current.CancellationToken); + + IDictionary data = infoBuilder.Build(); + string json = JsonSerializer.Serialize(data); + + json.Should().BeJson(""" + { + "git": { + "commit": { + "message": { + "full": "Fix null reference in health check\\n\\nAdds a null check before calling Ping().", + "short": "Fix null reference in health check" + } + } + } + } + """); + } + [Fact] public async Task Skips_malformed_lines_in_git_properties_file() { diff --git a/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs new file mode 100644 index 0000000000..52c8311ff8 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.AutoDetection; + +public sealed class AutoDetectionExplicitFalseWinsOverDetectedConsumingPackageReferenceTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); + + await testApp.BuildAsync("-p:GenerateGitProperties=false"); + testApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs new file mode 100644 index 0000000000..70524d2ec3 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.AutoDetection; + +public sealed class AutoDetectionGeneratesGitPropertiesWhenConsumingPackageReferencedTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); + await testApp.BuildAsync(); + + Dictionary properties = await testApp.ReadDebugPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideDetectsCustomPackageIdsTest.cs new file mode 100644 index 0000000000..6942d55953 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideDetectsCustomPackageIdsTest.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.AutoDetection; + +public sealed class AutoDetectionOverrideDetectsCustomPackageIdsTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + const string customPackageId = "Example.Package.Name"; + + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync(customPackageId); + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); + await testApp.BuildAsync($"-p:GitPropertiesConsumingPackageIds={customPackageId}"); + + Dictionary properties = await testApp.ReadDebugPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideDoesNotMatchPackageIdAsPrefixTest.cs new file mode 100644 index 0000000000..8149efa631 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideDoesNotMatchPackageIdAsPrefixTest.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.AutoDetection; + +public sealed class AutoDetectionOverrideDoesNotMatchPackageIdAsPrefixTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + const string shortPackageId = "Some"; + const string longerPackageId = "Some2"; + + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync(longerPackageId); + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); + await testApp.BuildAsync($"-p:GitPropertiesConsumingPackageIds={shortPackageId}"); + testApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs new file mode 100644 index 0000000000..5b22a08154 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.AutoDetection; + +public sealed class AutoDetectionOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest : GitPropertiesBuildTestBase +{ + // Global properties can't be reassigned by the project's own conditional default, so this reaches the task's PackageIds parameter as a genuinely + // empty string, not "unset". That parameter must NOT be [Required]: MSBuild treats empty the same as "not supplied" and would fail with MSB4044. + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); + await testApp.BuildAsync("-p:GitPropertiesConsumingPackageIds="); + testApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionSkipsGenerationWhenNoConsumingPackageReferenceTest.cs new file mode 100644 index 0000000000..44260a5c19 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/AutoDetection/AutoDetectionSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.AutoDetection; + +public sealed class AutoDetectionSkipsGenerationWhenNoConsumingPackageReferenceTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); + DotNetCommandOutput output = await testApp.BuildAsync("-v:normal"); + output.Value.Should().Contain("git.properties generation skipped: no reference to"); + testApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/GitDirtyStateUnknownWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitDirtyStateUnknownWarnsByDefaultTest.cs new file mode 100644 index 0000000000..e1f6f03695 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitDirtyStateUnknownWarnsByDefaultTest.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class GitDirtyStateUnknownWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + private const string BogusGitExecutable = "this-executable-definitely-does-not-exist-anywhere"; + + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync(); + repository.SharedCacheExists.Should().BeTrue(); + + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}"); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitDirtyStateUnknown, "failed ("); + repository.TestApp.GitPropertiesGenerated.Should().BeTrue(); + + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties.Should().NotContainKey("git.dirty"); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + + DotNetCommandOutput disableWarningsOutput = + await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false"); + + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitDirtyStateUnknown, "failed ("); + repository.TestApp.GitPropertiesGenerated.Should().BeTrue(); + + DotNetCommandOutput featureOffOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitDirtyStateUnknown); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + string nonZeroExitCodeGitExecutable = await GitPropertiesTestWorkspace.GetNonZeroExitCodeGitExecutableAsync(); + DotNetCommandOutput nonZeroExitCodeOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={nonZeroExitCodeGitExecutable}"); + nonZeroExitCodeOutput.Should().ContainGitWarning(GitDiagnosticId.GitDirtyStateUnknown, "exited with code"); + repository.TestApp.GitPropertiesGenerated.Should().BeTrue(); + + Dictionary nonZeroExitCodeProperties = await repository.TestApp.ReadDebugPropertiesAsync(); + nonZeroExitCodeProperties.Should().NotContainKey("git.dirty"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/GitExecutableNotFoundWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitExecutableNotFoundWarnsByDefaultTest.cs new file mode 100644 index 0000000000..097c5f64e4 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitExecutableNotFoundWarnsByDefaultTest.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class GitExecutableNotFoundWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + private const string BogusGitExecutable = "this-executable-definitely-does-not-exist-anywhere"; + + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}"); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitExecutableNotFound, "could not run"); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput disableWarningsOutput = + await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false"); + + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitExecutableNotFound, "could not run"); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput featureOffOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitExecutableNotFound); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + string nonZeroExitCodeGitExecutable = await GitPropertiesTestWorkspace.GetNonZeroExitCodeGitExecutableAsync(); + DotNetCommandOutput nonZeroExitCodeOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={nonZeroExitCodeGitExecutable}"); + nonZeroExitCodeOutput.Should().ContainGitWarning(GitDiagnosticId.GitExecutableNotFound, "exited with code"); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/GitPropertiesReportFileWritesCanBeDisabledTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitPropertiesReportFileWritesCanBeDisabledTest.cs new file mode 100644 index 0000000000..96dcfd2a7c --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitPropertiesReportFileWritesCanBeDisabledTest.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class GitPropertiesReportFileWritesCanBeDisabledTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + defaultOutput.Value.Should().Contain("git.properties: generating shared cache"); + defaultOutput.Value.Should().Contain("git.properties: writing to"); + defaultOutput.Value.Should().Contain("git.properties: writing fallback copy to"); + + repository.DeleteSharedCache(); + + DotNetCommandOutput disabledOutput = + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesReportFileWrites=false"); + + disabledOutput.Value.Should().NotContain("git.properties: generating shared cache"); + disabledOutput.Value.Should().NotContain("git.properties: writing to"); + disabledOutput.Value.Should().NotContain("git.properties: writing fallback copy to"); + + repository.TestApp.GitPropertiesGenerated.Should().BeTrue(); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/GitWorktreeWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitWorktreeWarnsByDefaultTest.cs new file mode 100644 index 0000000000..1ee0da47e3 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/GitWorktreeWarnsByDefaultTest.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class GitWorktreeWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + string projectDirectory = Workspace.GetPath("test-project"); + TestProject testApp = await Workspace.CreateProjectWithoutGitAsync("test-project"); + await Workspace.WriteFileAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/test-project"); + + DotNetCommandOutput defaultOutput = await testApp.BuildAsync(); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitWorktreeFound); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false"); + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitWorktreeFound); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput featureOffOutput = await testApp.BuildAsync("-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitWorktreeFound); + testApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/IncompatibleGitVersionWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/IncompatibleGitVersionWarnsByDefaultTest.cs new file mode 100644 index 0000000000..9058f9b581 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/IncompatibleGitVersionWarnsByDefaultTest.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class IncompatibleGitVersionWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string fakeGitExecutable = await Workspace.CreateFakeGitExecutableAsync("git version 2.14.9"); + + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={fakeGitExecutable}"); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.IncompatibleGitVersion); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput disableWarningsOutput = + await repository.TestApp.BuildAsync($"-p:GitExecutable={fakeGitExecutable}", "-p:GitPropertiesEnableWarnings=false"); + + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.IncompatibleGitVersion); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput featureOffOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={fakeGitExecutable}", "-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.IncompatibleGitVersion); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/NoCommitsWarnsByDefaultTest.cs new file mode 100644 index 0000000000..33935a45db --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/NoCommitsWarnsByDefaultTest.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class NoCommitsWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); + GitRepository repository = await emptyRepository.AddTestAppAsync(); + + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync(); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryHasNoCommits); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput disableWarningsOutput = await repository.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false"); + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitRepositoryHasNoCommits); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput featureOffOutput = await repository.TestApp.BuildAsync("-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryHasNoCommits); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/NoGitRepositoryWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/NoGitRepositoryWarnsByDefaultTest.cs new file mode 100644 index 0000000000..67cffc5847 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/NoGitRepositoryWarnsByDefaultTest.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class NoGitRepositoryWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + TestProject testApp = await Workspace.CreateProjectWithoutGitAsync("test-project"); + + DotNetCommandOutput defaultOutput = await testApp.BuildAsync(); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryNotFound, "no usable .git directory found"); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false"); + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitRepositoryNotFound, "no usable .git directory found"); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput featureOffOutput = await testApp.BuildAsync("-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryNotFound); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string fakeGitExecutable = await Workspace.CreateFakeGitExecutableAsync("git version 2.15.0"); + DotNetCommandOutput notInsideWorkTreeOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={fakeGitExecutable}"); + notInsideWorkTreeOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryNotFound, "not inside a usable git repository"); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/ShallowCloneWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/ShallowCloneWarnsByDefaultTest.cs new file mode 100644 index 0000000000..cacb3a05a6 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/ShallowCloneWarnsByDefaultTest.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class ShallowCloneWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 1); + GitRepository shallow = await source.CloneAsShallowAsync("shallow"); + + DotNetCommandOutput defaultOutput = await shallow.TestApp.BuildAsync(); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryIsShallowClone); + shallow.TestApp.GitPropertiesGenerated.Should().BeTrue(); + + shallow.DeleteSharedCache(); + DotNetCommandOutput disableWarningsOutput = await shallow.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false"); + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitRepositoryIsShallowClone); + shallow.TestApp.GitPropertiesGenerated.Should().BeTrue(); + + shallow.DeleteSharedCache(); + DotNetCommandOutput featureOffOutput = await shallow.TestApp.BuildAsync("-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryIsShallowClone); + shallow.TestApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Diagnostics/UnparseableGitVersionFailsBuildTest.cs b/src/Management/test/GitProperties.Build.Test/Diagnostics/UnparseableGitVersionFailsBuildTest.cs new file mode 100644 index 0000000000..7ccdee680a --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Diagnostics/UnparseableGitVersionFailsBuildTest.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.Diagnostics; + +public sealed class UnparseableGitVersionFailsBuildTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string fakeGitExecutable = await Workspace.CreateFakeGitExecutableAsync("invalid-version"); + + DotNetCommandOutput output = await repository.TestApp.BuildAsync(1, null, $"-p:GitExecutable={fakeGitExecutable}"); + output.Value.Should().Contain("could not parse the installed git version"); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/DotNetCommandOutput.cs b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutput.cs new file mode 100644 index 0000000000..57c480fedd --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutput.cs @@ -0,0 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal readonly record struct DotNetCommandOutput(string Value); diff --git a/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs new file mode 100644 index 0000000000..60aa3de634 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using FluentAssertions.Primitives; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class DotNetCommandOutputAssertions(DotNetCommandOutput subject) + : ReferenceTypeAssertions(subject) +{ + private const string DiagnosticPrefix = "GITPROPS"; + + protected override string Identifier => nameof(DotNetCommandOutput); + + [CustomAssertion] + public void ContainGitWarning(GitDiagnosticId diagnosticId, string? messageSnippet = null) + { + string code = FormatCode(diagnosticId); + AssertContainsDiagnosticLine("warning", code, messageSnippet); + } + + [CustomAssertion] + public void NotContainGitWarning(GitDiagnosticId diagnosticId) + { + string code = FormatCode(diagnosticId); + Subject.Value.Should().NotContain($"warning {code}"); + } + + [CustomAssertion] + public void NotContainAnyGitWarnings() + { + Subject.Value.Should().NotContain($"warning {DiagnosticPrefix}"); + } + + [CustomAssertion] + public void ContainGitMessage(GitDiagnosticId diagnosticId, string? messageSnippet = null) + { + string code = FormatCode(diagnosticId); + AssertContainsDiagnosticLine("message", code, messageSnippet); + } + + [CustomAssertion] + private void AssertContainsDiagnosticLine(string kind, string code, string? messageSnippet) + { + string marker = $"{kind} {code}"; + Subject.Value.Should().Contain(marker); + + if (messageSnippet != null) + { + string[] lines = Subject.Value.Split('\n'); + lines.Should().Contain(line => line.Contains(marker, StringComparison.Ordinal) && line.Contains(messageSnippet, StringComparison.Ordinal)); + } + } + + private static string FormatCode(GitDiagnosticId diagnosticId) + { + return $"{DiagnosticPrefix}{diagnosticId.Value:D3}"; + } +} diff --git a/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputExtensions.cs b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputExtensions.cs new file mode 100644 index 0000000000..5f82b10da9 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputExtensions.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal static class DotNetCommandOutputExtensions +{ + public static DotNetCommandOutputAssertions Should(this DotNetCommandOutput subject) + { + return new DotNetCommandOutputAssertions(subject); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs new file mode 100644 index 0000000000..3ee074830d --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class EmptyGitRepository(GitPropertiesTestWorkspace workspace, string rootDirectory) +{ + public string RootDirectory { get; } = rootDirectory; + + public Task RunGitAsync(params string[] arguments) + { + return ProcessRunner.RunGitAsync(RootDirectory, arguments); + } + + public Task CommitAllAsync(string subject, string? body = null) + { + return GitRepositoryBuilder.CommitAllAsync(RootDirectory, subject, body); + } + + public async Task AddTestAppAsync() + { + // Deliberately does not commit anything: any commit is the caller's own responsibility. + + TestProject testApp = await GitRepository.WriteDefaultTestAppAsync(RootDirectory); + return new GitRepository(workspace, RootDirectory, testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileIgnoredWhenLiveGitAvailableTest.cs new file mode 100644 index 0000000000..967aa2c4e0 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileIgnoredWhenLiveGitAvailableTest.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + await Workspace.WriteFileAsync(repository.TestApp.FallbackFilePath, ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:normal"); + output.Value.Should().NotContain("using pre-generated fallback file"); + + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileIsUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileIsUsedWhenNoGitAvailableTest.cs new file mode 100644 index 0000000000..cb7cb38e15 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileIsUsedWhenNoGitAvailableTest.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class FallbackFileIsUsedWhenNoGitAvailableTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2, true); + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); + fallbackProperties["git.dirty"].Should().Be("false"); + + RemotePushProjectTree remote = repository.SimulatePush("pushed"); + remote.HasGitDirectory.Should().BeFalse(); + remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + + DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:normal"); + output.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryNotFound); + output.Value.Should().Contain("using pre-generated fallback file"); + + Dictionary publishProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); + publishProperties.Should().BeEquivalentTo(fallbackProperties); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs new file mode 100644 index 0000000000..a76cb3a8e3 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + bool isDirty = await repository.IsDirtyAsync(); + isDirty.Should().BeTrue(); + + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties["git.dirty"].Should().Be("true"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs new file mode 100644 index 0000000000..44a90b9194 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + + Dictionary properties = await repository.TestApp.ReadFallbackPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + repository.TestApp.CompiledAssemblyExists.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs new file mode 100644 index 0000000000..8974b7a69b --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); + repository.TestApp.CompiledAssemblyExists.Should().BeFalse(); + await repository.TestApp.PublishAsync(1, "--no-build"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs new file mode 100644 index 0000000000..e91c0e8b01 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2, true); + await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); + + RemotePushProjectTree remote = repository.SimulatePush("pushed"); + remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + + DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:normal"); + output.Value.Should().Contain("using pre-generated fallback file"); + + Dictionary publishProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); + publishProperties.Should().BeEquivalentTo(fallbackProperties); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs new file mode 100644 index 0000000000..97d68e329d --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + await repository.TestApp.RestoreAsync(); + await repository.TestApp.BuildAsync("--no-restore", "-t:WriteGitPropertiesFallbackFile"); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs new file mode 100644 index 0000000000..824eb105ea --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + output.Value.Should().Contain($"git.properties: writing fallback copy to '{repository.TestApp.FallbackFilePath}'."); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); + Dictionary outputProperties1 = await repository.TestApp.ReadDebugPropertiesAsync(); + fallbackProperties.Should().BeEquivalentTo(outputProperties1); + + bool isDirty = await repository.IsDirtyAsync(); + isDirty.Should().BeFalse(); + + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + Dictionary outputProperties2 = await repository.TestApp.ReadDebugPropertiesAsync(); + outputProperties2["git.dirty"].Should().Be("false"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs new file mode 100644 index 0000000000..de41fd05ea --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFile/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.FallbackFile; + +public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : GitPropertiesBuildTestBase +{ + // "dotnet publish" runs its own target chain, independently of "dotnet build" - without this test, the fallback file could end up wired only into + // the build chain and never fire when publish is the first command run against a fresh checkout. + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + + DotNetCommandOutput output = + await repository.TestApp.PublishAsync("-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); + + output.Should().NotContainAnyGitWarnings(); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); + + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); + Dictionary publishProperties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); + fallbackProperties.Should().BeEquivalentTo(publishProperties); + + bool isDirty = await repository.IsDirtyAsync(); + isDirty.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitDiagnosticId.cs b/src/Management/test/GitProperties.Build.Test/GitDiagnosticId.cs new file mode 100644 index 0000000000..c2a326c773 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitDiagnosticId.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class GitDiagnosticId +{ + public static GitDiagnosticId GitRepositoryNotFound { get; } = new(1); + public static GitDiagnosticId GitWorktreeFound { get; } = new(2); + public static GitDiagnosticId GitExecutableNotFound { get; } = new(3); + public static GitDiagnosticId IncompatibleGitVersion { get; } = new(4); + public static GitDiagnosticId GitRepositoryHasNoCommits { get; } = new(5); + public static GitDiagnosticId GitRepositoryIsShallowClone { get; } = new(6); + public static GitDiagnosticId GitDirtyStateUnknown { get; } = new(7); + + public int Value { get; } + + private GitDiagnosticId(int value) + { + Value = value; + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs new file mode 100644 index 0000000000..71486ba44f --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +/// +/// Shared workspace lifecycle for every test in this project. Deliberately one test per class rather than many [Fact] methods on one shared +/// class: xUnit v3 parallelizes across test classes but never across methods within the same class, and every test here is dominated by "dotnet +/// build"/"publish" subprocess time, so this lets the suite's wall-clock approach its slowest single test instead of the sum of all of them. +/// +public abstract class GitPropertiesBuildTestBase : IAsyncLifetime +{ + internal GitPropertiesTestWorkspace Workspace { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + Workspace = await GitPropertiesTestWorkspace.CreateAsync(); + } + + public ValueTask DisposeAsync() + { + Workspace.Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs new file mode 100644 index 0000000000..bc1b3569f6 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class GitPropertiesTestWorkspace : IDisposable +{ + public const string TestAppProjectName = "TestApp"; + + private static readonly Task NonZeroExitCodeGitExecutableTask = GetOrCreateNonZeroExitCodeGitExecutableAsync(); + + public string RootDirectory { get; } + + private GitPropertiesTestWorkspace(string rootDirectory) + { + RootDirectory = rootDirectory; + } + + public static async Task CreateAsync() + { + string rootDirectory = Path.Combine(Path.GetTempPath(), $"build-tasks-test_{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8]}"); + Directory.CreateDirectory(rootDirectory); + string physicalRootDirectory = await ResolvePhysicalPathAsync(rootDirectory); + return new GitPropertiesTestWorkspace(physicalRootDirectory); + } + + private static async Task ResolvePhysicalPathAsync(string path) + { + if (OperatingSystem.IsMacOS()) + { + // On macOS, $TMPDIR resolves through a symlink (/var -> /private/var). + string output = await ProcessRunner.RunPwdAsync(path); + return output.Trim(); + } + + return path; + } + + public void Dispose() + { + try + { + // git marks files under .git\objects read-only on Windows, which makes a plain recursive delete throw UnauthorizedAccessException. + ClearReadOnlyAttributes(new DirectoryInfo(RootDirectory)); + Directory.Delete(RootDirectory, true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup only: a transiently locked file (e.g. an antivirus scan) must not fail the test run. + } + } + + private static void ClearReadOnlyAttributes(DirectoryInfo directory) + { + foreach (FileInfo file in directory.GetFiles()) + { + file.Attributes = FileAttributes.Normal; + } + + foreach (DirectoryInfo subDirectory in directory.GetDirectories()) + { + ClearReadOnlyAttributes(subDirectory); + } + } + + public string GetPath(string name) + { + return Path.Combine(RootDirectory, name); + } + + public async Task CreateProjectWithoutGitAsync(string name) + { + string directory = GetPath(name); + Directory.CreateDirectory(directory); + string appDirectory = await TestProjectWriter.CopyCurrentProjectFilesAsync(directory); + return new TestProject(appDirectory, TestAppProjectName); + } + + public async Task CreateFakeGitExecutableAsync(string versionOutput) + { + string projectDirectory = await TestProjectWriter.WriteFakeGitExecutableProjectAsync(RootDirectory, "FakeGit", versionOutput); + await ProcessRunner.RunDotNetAsync(projectDirectory, 0, null, "build"); + + string executableName = OperatingSystem.IsWindows() ? "FakeGit.exe" : "FakeGit"; + return Path.Combine(projectDirectory, "bin", "Debug", TestAppTargetFramework.Default, executableName); + } + + public static Task GetNonZeroExitCodeGitExecutableAsync() + { + return NonZeroExitCodeGitExecutableTask; + } + + private static async Task GetOrCreateNonZeroExitCodeGitExecutableAsync() + { + string projectDirectory = Path.Combine(Path.GetTempPath(), "steeltoe-nonzero-exit-git", "NonZeroExitCodeGit"); + string executableName = OperatingSystem.IsWindows() ? "NonZeroExitCodeGit.exe" : "NonZeroExitCodeGit"; + string executablePath = Path.Combine(projectDirectory, "bin", "Debug", TestAppTargetFramework.Default, executableName); + + if (!File.Exists(executablePath)) + { + await TestProjectWriter.WriteNonZeroExitCodeGitExecutableProjectAsync(projectDirectory, "NonZeroExitCodeGit"); + await ProcessRunner.RunDotNetAsync(projectDirectory, 0, null, "build"); + } + + return executablePath; + } + + public async Task CreateEmptyRepositoryAsync(string name) + { + string directory = GetPath(name); + await GitRepositoryBuilder.InitializeEmptyAsync(directory); + return new EmptyGitRepository(this, directory); + } + + public async Task CreateGitRepositoryAsync(string name, int commitCount, bool includeFallbackFileInGitignore = false) + { + string directory = GetPath(name); + await GitRepositoryBuilder.InitializeAsync(directory, commitCount, includeFallbackFileInGitignore); + TestProject testApp = await GitRepository.WriteDefaultTestAppAsync(directory); + var repository = new GitRepository(this, directory, testApp); + await GitRepositoryBuilder.CommitAllAsync(directory, "Add project files"); + return repository; + } + + public Task PackGitPropertiesBuildToFeedAsync() + { + return TestProjectWriter.PackGitPropertiesBuildToFeedAsync(RootDirectory); + } + + public Task GetPackageIdAsync() + { + return TestProjectWriter.GetPackageIdAsync(); + } + + public Task WriteIsolatedNuGetConfigAsync(TestProject project, string feedDirectory) + { + return TestProjectWriter.WriteNuGetConfigAsync(Path.Combine(project.RootDirectory, "nuget.config"), feedDirectory); + } + + public async Task WriteFileAsync(string path, string contents) + { + await File.WriteAllTextAsync(path, contents, TestContext.Current.CancellationToken); + } + + public async Task WriteFileAsync(string path, IEnumerable lines) + { + await File.WriteAllLinesAsync(path, lines, TestContext.Current.CancellationToken); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitRepository.cs b/src/Management/test/GitProperties.Build.Test/GitRepository.cs new file mode 100644 index 0000000000..c4e1473c8a --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitRepository.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class GitRepository(GitPropertiesTestWorkspace workspace, string rootDirectory, TestProject testApp) +{ + private readonly string _sharedCacheFilePath = Path.Combine(rootDirectory, "obj", "_GitProperties", "git.properties.cache"); + + public TestProject TestApp { get; } = testApp; + public bool SharedCacheExists => File.Exists(_sharedCacheFilePath); + + public Task RunGitAsync(params string[] arguments) + { + return ProcessRunner.RunGitAsync(rootDirectory, arguments); + } + + public Task GetCommitIdAsync() + { + return RunGitAsync("rev-parse", "HEAD"); + } + + public async Task IsDirtyAsync() + { + string status = await RunGitAsync("status", "--porcelain"); + return status.Length > 0; + } + + public Task TagAsync(string name, string? commitId = null) + { + return commitId == null ? RunGitAsync("tag", name) : RunGitAsync("tag", name, commitId); + } + + public async Task AddProjectAsync(string name, IEnumerable? targetFrameworks = null, bool? generateGitProperties = true, + string? extraItemGroupContent = null) + { + string projectDirectory = await TestProjectWriter.WriteAppProjectAsync(rootDirectory, name, targetFrameworks, generateGitProperties, + extraItemGroupContent); + + return new TestProject(projectDirectory, name); + } + + public async Task AddDependencyProjectAsync(string name) + { + string projectDirectory = await TestProjectWriter.WriteDummyDependencyProjectAsync(rootDirectory, name); + return new TestProject(projectDirectory, name); + } + + public Task AddTestAppReferencingAsync(TestProject dependency) + { + string extraItemGroupContent = dependency.ToProjectReferenceXml(); + return AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: extraItemGroupContent); + } + + public async Task AddPackageConsumerProjectAsync(string name, string packageVersion) + { + string projectDirectory = Path.Combine(rootDirectory, name); + await TestProjectWriter.CreatePackageConsumerProjectAsync(projectDirectory, packageVersion); + return new TestProject(projectDirectory, name); + } + + public async Task CloneAsShallowAsync(string name, int depth = 1) + { + string destination = workspace.GetPath(name); + // --no-local is required: for a local path, git's local-clone optimization otherwise bypasses shallow-transfer logic entirely and silently ignores --depth, producing a full clone. + await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", $"{depth}", rootDirectory, destination); + + TestProject shallowTestApp = await WriteDefaultTestAppAsync(destination); + return new GitRepository(workspace, destination, shallowTestApp); + } + + public void DeleteSharedCache() + { + File.Delete(_sharedCacheFilePath); + } + + public RemotePushProjectTree SimulatePush(string name) + { + string destination = workspace.GetPath(name); + GitRepositoryBuilder.SimulateSourcePush(rootDirectory, destination); + + var pushedTestApp = new TestProject(Path.Combine(destination, GitPropertiesTestWorkspace.TestAppProjectName), + GitPropertiesTestWorkspace.TestAppProjectName); + + return new RemotePushProjectTree(destination, pushedTestApp); + } + + internal static async Task WriteDefaultTestAppAsync(string repositoryDirectory) + { + string appDirectory = await TestProjectWriter.CopyCurrentProjectFilesAsync(repositoryDirectory); + return new TestProject(appDirectory, GitPropertiesTestWorkspace.TestAppProjectName); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs new file mode 100644 index 0000000000..c9b796df8a --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal static class GitRepositoryBuilder +{ + private static readonly HashSet DirectoryNamesExcludedInPush = new(StringComparer.OrdinalIgnoreCase) + { + ".git", + "bin", + "obj" + }; + + public static async Task InitializeEmptyAsync(string destination) + { + Directory.CreateDirectory(destination); + await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); + } + + public static async Task InitializeAsync(string destination, int commitCount, bool includeFallbackFileInGitignore) + { + await InitializeEmptyAsync(destination); + await ProcessRunner.RunGitAsync(destination, "config", "user.name", "Test User"); + await ProcessRunner.RunGitAsync(destination, "config", "user.email", "test@example.com"); + + string gitignoreContent = includeFallbackFileInGitignore + ? """ + bin/ + obj/ + git.properties + """ + : """ + bin/ + obj/ + """; + + await File.WriteAllTextAsync(Path.Combine(destination, ".gitignore"), gitignoreContent, TestContext.Current.CancellationToken); + + for (int commitNumber = 1; commitNumber <= commitCount; commitNumber++) + { + await File.WriteAllTextAsync(Path.Combine(destination, $"file{commitNumber}.txt"), $"content {commitNumber}", + TestContext.Current.CancellationToken); + + await CommitAllAsync(destination, $"Commit {commitNumber}"); + } + } + + public static async Task CommitAllAsync(string repositoryDirectory, string subject, string? body = null) + { + await ProcessRunner.RunGitAsync(repositoryDirectory, "add", "-A"); + + if (body == null) + { + await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", subject); + } + else + { + await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", subject, "-m", body); + } + } + + public static void SimulateSourcePush(string sourceDirectory, string destinationDirectory) + { + CopyDirectoryExcluding(new DirectoryInfo(sourceDirectory), destinationDirectory, DirectoryNamesExcludedInPush); + } + + private static void CopyDirectoryExcluding(DirectoryInfo source, string destination, HashSet excludedDirectoryNames) + { + Directory.CreateDirectory(destination); + + foreach (FileInfo file in source.GetFiles()) + { + file.CopyTo(Path.Combine(destination, file.Name), true); + } + + foreach (DirectoryInfo subDirectory in source.GetDirectories()) + { + if (excludedDirectoryNames.Contains(subDirectory.Name)) + { + continue; + } + + CopyDirectoryExcluding(subDirectory, Path.Combine(destination, subDirectory.Name), excludedDirectoryNames); + } + } +} diff --git a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs new file mode 100644 index 0000000000..378e581b38 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -0,0 +1,180 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal static class ProcessRunner +{ + private static readonly string LocatorCommand = OperatingSystem.IsWindows() ? "where" : "which"; + + private static readonly char[] LineSeparators = + [ + '\r', + '\n' + ]; + + /// + /// Generous enough to cover the slowest command this suite runs (a Release build plus NuGet pack) under heavy load, while still turning a genuine hang + /// into an informative test failure instead of blocking the whole suite indefinitely. + /// + private static readonly TimeSpan ProcessExitTimeout = TimeSpan.FromMinutes(2); + + private static readonly Task RealGitExecutableTask = ResolveGitExecutableAsync(); + + private static async Task ResolveGitExecutableAsync() + { + string output = await RunAsync(LocatorCommand, Path.GetTempPath(), 0, null, CancellationToken.None, "git"); + string? firstLine = output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + + if (firstLine == null) + { + throw new InvalidOperationException($"Could not resolve the location of git via '{LocatorCommand} git'."); + } + + return firstLine; + } + + public static Task RunGitAsync(string workingDirectory, params string[] arguments) + { + return RunGitAsync(workingDirectory, TestContext.Current.CancellationToken, arguments); + } + + public static async Task RunGitAsync(string workingDirectory, CancellationToken cancellationToken, params string[] arguments) + { + string gitExecutable = await RealGitExecutableTask; + string output = await RunAsync(gitExecutable, workingDirectory, 0, null, cancellationToken, arguments); + return output.Trim(); + } + + public static Task RunDotNetAsync(string workingDirectory, int exitCodeExpected, Dictionary? environmentVariables, + params string[] arguments) + { + string[] dotNetArguments = + [ + .. arguments, + "-p:RunAnalyzers=false", + "-p:NuGetAudit=false" + ]; + + var dotNetEnvironmentVariables = new Dictionary + { + // Without this, a spawned "dotnet build"/"publish" leaves a persistent MSBuild worker node running in the background for reuse by a later + // build. That node inherits our redirected stdout/stderr pipe handles and keeps them open after the process we launched exits, so the read end + // never sees EOF and awaiting exit below would block forever even though the build already completed successfully. + ["MSBUILDDISABLENODEREUSE"] = "1" + }; + + foreach ((string name, string value) in environmentVariables ?? []) + { + dotNetEnvironmentVariables[name] = value; + } + + return RunAsync("dotnet", workingDirectory, exitCodeExpected, dotNetEnvironmentVariables, TestContext.Current.CancellationToken, dotNetArguments); + } + + public static Task RunPwdAsync(string workingDirectory) + { + return RunAsync("pwd", workingDirectory, 0, null, TestContext.Current.CancellationToken, "-P"); + } + + private static async Task RunAsync(string fileName, string workingDirectory, int exitCodeExpected, Dictionary? environmentVariables, + CancellationToken cancellationToken, params string[] arguments) + { + var outputBuilder = new StringBuilder(); + Lock outputLock = new(); + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach ((string key, string name) in environmentVariables ?? []) + { + startInfo.EnvironmentVariables[key] = name; + } + + using var process = new Process(); + process.StartInfo = startInfo; + process.OutputDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data); + process.ErrorDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(ProcessExitTimeout); + + try + { + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + KillEntireProcessTreeInBackground(process.Id); + + if (cancellationToken.IsCancellationRequested) + { + throw; + } + + throw new TimeoutException($"'{fileName} {string.Join(' ', arguments)}' in '{workingDirectory}' did not exit within {ProcessExitTimeout}."); + } + + string output = outputBuilder.ToString(); + + process.ExitCode.Should().Be(exitCodeExpected, "'{0} {1}' in '{2}' was expected to exit with code {3}. Output:\n{4}", fileName, + string.Join(' ', arguments), workingDirectory, exitCodeExpected, output); + + return output; + + void AppendLine(string? line) + { + if (line == null) + { + return; + } + +#pragma warning disable S6507 // Blocks should not be synchronized on local variables + // Justification: Deliberately a call-scoped lock, not a shared static one: a global lock would serialize stdout/stderr callbacks + // across every concurrently running process, starving the thread pool under high-volume output (e.g. "dotnet build -v:detailed"). + lock (outputLock) +#pragma warning restore S6507 // Blocks should not be synchronized on local variables + { + outputBuilder.AppendLine(line); + } + } + } + + private static void KillEntireProcessTreeInBackground(int processId) + { + // Fire-and-forget, so that pressing the Stop button in an IDE responds immediately. + _ = Task.Run(() => + { + try + { + using var process = Process.GetProcessById(processId); + process.Kill(true); + } + catch (Exception) + { + // Best-effort kill of an already-timed-out process. + } + }); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Properties/AssemblyInfo.cs b/src/Management/test/GitProperties.Build.Test/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..791c4affd6 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] diff --git a/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs b/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs new file mode 100644 index 0000000000..ed9300e1d3 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal static class PropertiesFile +{ + public static async Task> ReadAsync(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"git.properties not found at: {path}"); + } + + var map = new Dictionary(); + + foreach (string line in await File.ReadAllLinesAsync(path, Encoding.UTF8, TestContext.Current.CancellationToken)) + { + if (!line.StartsWith("git.", StringComparison.Ordinal)) + { + continue; + } + + int equalsIndex = line.IndexOf('='); + + if (equalsIndex < 0) + { + continue; + } + + map[line[..equalsIndex]] = line[(equalsIndex + 1)..]; + } + + return map; + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PropertyContent/BranchEnvironmentVariableOverridesBranchNameTest.cs b/src/Management/test/GitProperties.Build.Test/PropertyContent/BranchEnvironmentVariableOverridesBranchNameTest.cs new file mode 100644 index 0000000000..7c06d07f91 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PropertyContent/BranchEnvironmentVariableOverridesBranchNameTest.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.PropertyContent; + +public sealed class BranchEnvironmentVariableOverridesBranchNameTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + + var environmentVariables = new Dictionary + { + ["GITHUB_HEAD_REF"] = "feature/from-ci" + }; + + await repository.TestApp.BuildAsync(0, environmentVariables); + + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties["git.branch"].Should().Be("feature/from-ci"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PropertyContent/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/PropertyContent/GroundTruthAllPropertiesMatchGitTest.cs new file mode 100644 index 0000000000..a233396f28 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PropertyContent/GroundTruthAllPropertiesMatchGitTest.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace Steeltoe.Management.GitProperties.Build.Test.PropertyContent; + +public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTestBase +{ + // Also piggybacks two unrelated checks on this same build rather than paying for another subprocess: that the fallback file is never written + // unless explicitly opted into, and that writing git.properties is confirmed at default verbosity. + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 3); + DotNetCommandOutput output = await repository.TestApp.BuildAsync(); + + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeFalse(); + + string expectedPath = Path.Combine(repository.TestApp.RootDirectory, "obj", "Debug", TestAppTargetFramework.Default, "git.properties"); + output.Value.Should().Contain($"git.properties: writing to '{expectedPath}'."); + + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + + string expectedCommitIdAbbrev = await repository.RunGitAsync("rev-parse", "--short=7", "HEAD"); + properties["git.commit.id.abbrev"].Should().Be(expectedCommitIdAbbrev); + + string expectedCommitUserName = await repository.RunGitAsync("log", "-1", "--format=%an"); + properties["git.commit.user.name"].Should().Be(expectedCommitUserName); + + string expectedCommitUserEmail = await repository.RunGitAsync("log", "-1", "--format=%ae"); + properties["git.commit.user.email"].Should().Be(expectedCommitUserEmail); + + string expectedCommitMessageShort = await repository.RunGitAsync("log", "-1", "--format=%s"); + properties["git.commit.message.short"].Should().Be(expectedCommitMessageShort); + + string expectedTotalCommitCount = await repository.RunGitAsync("rev-list", "--count", "HEAD"); + properties["git.total.commit.count"].Should().Be(expectedTotalCommitCount); + + bool expectedDirty = await repository.IsDirtyAsync(); + properties["git.dirty"].Should().Be(expectedDirty ? "true" : "false"); + + // SDK default when $(Version) isn't set. + properties["git.build.version"].Should().Be("1.0.0"); + + DateTimeOffset.TryParse(properties["git.build.time"], CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset buildTime).Should().BeTrue( + "git.build.time must be a parseable, ISO-8601-with-offset timestamp, matching the style git itself uses for git.commit.time."); + + buildTime.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromMinutes(5)); + + string[] expectedKeys = + [ + "git.branch", + "git.commit.id", + "git.commit.id.abbrev", + "git.commit.id.describe", + "git.commit.time", + "git.commit.message.short", + "git.commit.message.full", + "git.commit.user.name", + "git.commit.user.email", + "git.build.host", + "git.build.user.name", + "git.build.user.email", + "git.tags", + "git.closest.tag.name", + "git.closest.tag.commit.count", + "git.remote.origin.url", + "git.total.commit.count", + "git.dirty", + "git.build.version", + "git.build.time" + ]; + + properties.Keys.Should().BeEquivalentTo(expectedKeys); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PropertyContent/MultipleRemotesOnlyOriginUrlIsUsedTest.cs b/src/Management/test/GitProperties.Build.Test/PropertyContent/MultipleRemotesOnlyOriginUrlIsUsedTest.cs new file mode 100644 index 0000000000..40082f4d6a --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PropertyContent/MultipleRemotesOnlyOriginUrlIsUsedTest.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.PropertyContent; + +public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.RunGitAsync("remote", "add", "upstream", "https://example.com/upstream.git"); + await repository.RunGitAsync("remote", "add", "origin", "https://example.com/origin.git"); + await repository.RunGitAsync("remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); + await repository.TestApp.BuildAsync(); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git"); + + await repository.RunGitAsync("remote", "remove", "origin"); + await repository.RunGitAsync("remote", "add", "origin", "git@github.com:org/repo.git"); + await repository.TestApp.BuildAsync(); + Dictionary propertiesAfterScpStyleUrl = await repository.TestApp.ReadDebugPropertiesAsync(); + propertiesAfterScpStyleUrl["git.remote.origin.url"].Should().Be("git@github.com:org/repo.git"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PropertyContent/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/PropertyContent/NonAsciiCommitDataRendersCorrectlyTest.cs new file mode 100644 index 0000000000..a920767727 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PropertyContent/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.PropertyContent; + +public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + const string nonAsciiUserName = "\u00DCn\u00EFc\u00F6d\u00E9 T\u00EBst"; + const string nonAsciiCommitSubject = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; + const string commitBody = "Adds a null check before calling Ping()."; + + EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); + await emptyRepository.RunGitAsync("config", "user.name", nonAsciiUserName); + await emptyRepository.RunGitAsync("config", "user.email", "test@example.com"); + await Workspace.WriteFileAsync(Path.Combine(emptyRepository.RootDirectory, ".gitignore"), "bin/\r\nobj/\r\n"); + await Workspace.WriteFileAsync(Path.Combine(emptyRepository.RootDirectory, "file.txt"), "content"); + await emptyRepository.CommitAllAsync(nonAsciiCommitSubject, commitBody); + + GitRepository repository = await emptyRepository.AddTestAppAsync(); + await repository.TestApp.BuildAsync(); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties["git.commit.user.name"].Should().Be(nonAsciiUserName); + properties["git.commit.message.short"].Should().Be(nonAsciiCommitSubject); + properties["git.commit.message.full"].Should().Be($@"{nonAsciiCommitSubject}\n\n{commitBody}"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PropertyContent/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/PropertyContent/ShallowCloneLeavesCommitCountsEmptyTest.cs new file mode 100644 index 0000000000..6d2c75a3f4 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PropertyContent/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.PropertyContent; + +public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 3); + await source.TagAsync("v1.0.0"); + GitRepository shallow = await source.CloneAsShallowAsync("shallow"); + string isShallowRepository = await shallow.RunGitAsync("rev-parse", "--is-shallow-repository"); + isShallowRepository.Should().Be("true"); + + DotNetCommandOutput output = await shallow.TestApp.BuildAsync(); + output.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryNotFound); + output.Should().NotContainGitWarning(GitDiagnosticId.GitWorktreeFound); + output.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryIsShallowClone); + + Dictionary properties = await shallow.TestApp.ReadDebugPropertiesAsync(); + properties["git.total.commit.count"].Should().BeEmpty(); + properties["git.closest.tag.commit.count"].Should().BeEmpty(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PublishPush/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishPush/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs new file mode 100644 index 0000000000..6cdfa2b18e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PublishPush/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; + +namespace Steeltoe.Management.GitProperties.Build.Test.PublishPush; + +public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); + string packageId = await Workspace.GetPackageIdAsync(); + string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); + nuPkgFiles.Should().ContainSingle(); + + var nuPkgVersionRegex = new Regex($@"^{Regex.Escape(packageId)}\.(.+)\.nupkg$", RegexOptions.None, TimeSpan.FromSeconds(1)); + Match versionMatch = nuPkgVersionRegex.Match(Path.GetFileName(nuPkgFiles[0])); + versionMatch.Success.Should().BeTrue(); + + string packageVersion = versionMatch.Groups[1].Value; + TestProject consumer = await repository.AddPackageConsumerProjectAsync("Consumer", packageVersion); + await Workspace.WriteIsolatedNuGetConfigAsync(consumer, feedDirectory); + string isolatedPackagesPath = Workspace.GetPath("isolated-packages"); + DotNetCommandOutput output = await consumer.BuildAsync($"-p:RestorePackagesPath={isolatedPackagesPath}"); + output.Value.Should().Contain("0 Warning(s)"); + +#pragma warning disable S4040 + // Justification: NuGet always lowercases the package ID for the on-disk global-packages-folder layout. + string lowerCasePackageId = packageId.ToLowerInvariant(); +#pragma warning restore S4040 + Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should().BeTrue(); + + Dictionary properties = await consumer.ReadDebugPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PublishPush/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishPush/PublishIncludesGitPropertiesTest.cs new file mode 100644 index 0000000000..e997e16b63 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PublishPush/PublishIncludesGitPropertiesTest.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.PublishPush; + +public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.PublishAsync(); + + Dictionary properties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PublishPush/PublishNoBuildIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishPush/PublishNoBuildIncludesGitPropertiesTest.cs new file mode 100644 index 0000000000..74cec6a10e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PublishPush/PublishNoBuildIncludesGitPropertiesTest.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.PublishPush; + +public sealed class PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync("-c", "Release"); + await repository.TestApp.PublishAsync("-c", "Release", "--no-build"); + + Dictionary properties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs b/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs new file mode 100644 index 0000000000..4d3ec9d3ea --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class RemotePushProjectTree(string rootDirectory, TestProject testApp) +{ + public string RootDirectory { get; } = rootDirectory; + public TestProject TestApp { get; } = testApp; + + public bool HasGitDirectory => Directory.Exists(Path.Combine(RootDirectory, ".git")); +} diff --git a/src/Management/test/GitProperties.Build.Test/SharedCache/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs b/src/Management/test/GitProperties.Build.Test/SharedCache/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs new file mode 100644 index 0000000000..5402decec6 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SharedCache/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.SharedCache; + +public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync(); + Dictionary propertiesBefore = await repository.TestApp.ReadDebugPropertiesAsync(); + + await Task.Delay(TimeSpan.FromMilliseconds(1100), TestContext.Current.CancellationToken); + await repository.TestApp.BuildAsync(); + Dictionary propertiesAfter = await repository.TestApp.ReadDebugPropertiesAsync(); + + propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"]); + propertiesAfter["git.commit.time"].Should().Be(propertiesBefore["git.commit.time"]); + propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"]); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SharedCache/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/SharedCache/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs new file mode 100644 index 0000000000..d67a59d635 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SharedCache/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.SharedCache; + +public sealed class IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync(); + repository.SharedCacheExists.Should().BeTrue(); + + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:normal"); + output.Value.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); + + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties.Should().ContainKey("git.dirty"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SharedCache/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/SharedCache/MultiProjectSharesCacheTest.cs new file mode 100644 index 0000000000..e7ae234522 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SharedCache/MultiProjectSharesCacheTest.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.SharedCache; + +public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2); + TestProject projectA = await repository.AddProjectAsync("ProjectA"); + TestProject projectB = await repository.AddProjectAsync("ProjectB"); + DotNetCommandOutput outputA = await projectA.BuildAsync(); + outputA.Value.Should().Contain("git.properties: generating shared cache"); + repository.SharedCacheExists.Should().BeTrue(); + + DotNetCommandOutput outputB = await projectB.BuildAsync(); + outputB.Value.Should().NotContain("git.properties: generating shared cache"); + + Dictionary propertiesA = await projectA.ReadDebugPropertiesAsync(); + Dictionary propertiesB = await projectB.ReadDebugPropertiesAsync(); + propertiesB["git.commit.id"].Should().Be(propertiesA["git.commit.id"]); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SharedCache/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/SharedCache/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs new file mode 100644 index 0000000000..83fe13948a --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SharedCache/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.SharedCache; + +public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject testApp = await repository.AddProjectAsync("MultiTargetApp", TestAppTargetFramework.Multiple); + await testApp.BuildAsync(); + + string expectedCommitId = await repository.GetCommitIdAsync(); + List> propertiesBefore = await testApp.ReadDebugPropertiesPerTargetFrameworkAsync(TestAppTargetFramework.Multiple); + + foreach (Dictionary properties in propertiesBefore) + { + properties["git.commit.id"].Should().Be(expectedCommitId); + properties["git.tags"].Should().BeEmpty(); + } + + await repository.TagAsync("v1.0.0"); + await testApp.BuildAsync(); + List> propertiesAfter = await testApp.ReadDebugPropertiesPerTargetFrameworkAsync(TestAppTargetFramework.Multiple); + + foreach (Dictionary properties in propertiesAfter) + { + properties["git.tags"].Should().Be("v1.0.0"); + } + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SharedCache/NewTagInvalidatesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/SharedCache/NewTagInvalidatesCacheTest.cs new file mode 100644 index 0000000000..ca7ce8135f --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SharedCache/NewTagInvalidatesCacheTest.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test.SharedCache; + +public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Test() + { + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync(); + Dictionary propertiesBefore = await repository.TestApp.ReadDebugPropertiesAsync(); + propertiesBefore["git.tags"].Should().BeEmpty(); + + string ancestorCommitId = await repository.RunGitAsync("rev-parse", "HEAD~1"); + await repository.TagAsync("release-1.0", ancestorCommitId); + await repository.TestApp.BuildAsync(); + Dictionary propertiesAfter = await repository.TestApp.ReadDebugPropertiesAsync(); + propertiesAfter["git.tags"].Should().BeEmpty("the tag points at an ancestor, not HEAD, so it must not show up in git.tags."); + propertiesAfter["git.closest.tag.name"].Should().Be("release-1.0"); + propertiesAfter["git.closest.tag.commit.count"].Should().Be("1"); + propertiesAfter["git.commit.id.describe"].Should().Be("release-1.0-1"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj b/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj new file mode 100644 index 0000000000..2ae2b22af6 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + + + + + + + + + + + + + + diff --git a/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs b/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs new file mode 100644 index 0000000000..f50113a03c --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/TestAppTargetFramework.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal static partial class TestAppTargetFramework +{ + public static readonly string Default = Resolve(); + public static readonly string[] Multiple = ResolveMultiple(); + + private static string Resolve() + { + AssemblyMetadataAttribute? attribute = Assembly.GetExecutingAssembly().GetCustomAttributes() + .FirstOrDefault(candidate => candidate.Key == "TargetFramework"); + + if (attribute?.Value == null) + { + throw new InvalidOperationException("Could not resolve this test assembly's own TargetFramework from its AssemblyMetadata."); + } + + return attribute.Value; + } + + private static string[] ResolveMultiple() + { + Match match = NetTfmRegex().Match(Default); + + if (!match.Success) + { + throw new InvalidOperationException($"Could not parse a 'netX.0'-style TFM from '{Default}'."); + } + + int majorVersion = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + + return + [ + Default, + $"net{majorVersion - 1}.0" + ]; + } + + [GeneratedRegex(@"^net(\d+)\.0$")] + private static partial Regex NetTfmRegex(); +} diff --git a/src/Management/test/GitProperties.Build.Test/TestProject.cs b/src/Management/test/GitProperties.Build.Test/TestProject.cs new file mode 100644 index 0000000000..29d3439bd8 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/TestProject.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal sealed class TestProject(string rootDirectory, string name) +{ + private readonly string _debugGitPropertiesFilePath = Path.Combine(rootDirectory, "bin", "Debug", TestAppTargetFramework.Default, "git.properties"); + + private readonly string _releasePublishGitPropertiesFilePath = + Path.Combine(rootDirectory, "bin", "Release", TestAppTargetFramework.Default, "publish", "git.properties"); + + private bool _hasRestored; + + public string RootDirectory { get; } = rootDirectory; + public string Name { get; } = name; + + public string FallbackFilePath { get; } = Path.Combine(rootDirectory, "git.properties"); + public bool GitPropertiesGenerated => File.Exists(_debugGitPropertiesFilePath); + public bool FallbackGitPropertiesGenerated => File.Exists(FallbackFilePath); + public bool CompiledAssemblyExists => File.Exists(Path.Combine(RootDirectory, "bin", "Debug", TestAppTargetFramework.Default, $"{Name}.dll")); + + public string ToProjectReferenceXml() + { + return $""""""; + } + + public async Task BuildAsync(params string[] arguments) + { + return await BuildAsync(0, null, arguments); + } + + public async Task BuildAsync(int exitCodeExpected, Dictionary? environmentVariables, params string[] arguments) + { + return await RunDotNetCommandAsync("build", exitCodeExpected, environmentVariables, arguments); + } + + public async Task PublishAsync(params string[] arguments) + { + return await PublishAsync(0, arguments); + } + + public async Task PublishAsync(int exitCodeExpected, params string[] arguments) + { + return await RunDotNetCommandAsync("publish", exitCodeExpected, null, arguments); + } + + public async Task RestoreAsync(params string[] arguments) + { + return await RunDotNetCommandAsync("restore", 0, null, arguments); + } + + private async Task RunDotNetCommandAsync(string command, int exitCodeExpected, Dictionary? environmentVariables, + params string[] arguments) + { + // Avoid redundant restore of repeated build/publish calls in the same test to improve performance. + bool skipRestore = _hasRestored && command != "restore"; + + string output = await ProcessRunner.RunDotNetAsync(RootDirectory, exitCodeExpected, environmentVariables, [ + command, + .. skipRestore ? ["--no-restore"] : Array.Empty(), + .. arguments + ]); + + _hasRestored = true; + return new DotNetCommandOutput(output); + } + + public Task> ReadDebugPropertiesAsync() + { + return PropertiesFile.ReadAsync(_debugGitPropertiesFilePath); + } + + public Task> ReadReleasePublishPropertiesAsync() + { + return PropertiesFile.ReadAsync(_releasePublishGitPropertiesFilePath); + } + + public Task> ReadFallbackPropertiesAsync() + { + return PropertiesFile.ReadAsync(FallbackFilePath); + } + + public async Task>> ReadDebugPropertiesPerTargetFrameworkAsync(IEnumerable targetFrameworks) + { + List> result = []; + + foreach (string targetFramework in targetFrameworks) + { + string path = Path.Combine(RootDirectory, "bin", "Debug", targetFramework, "git.properties"); + Dictionary properties = await PropertiesFile.ReadAsync(path); + result.Add(properties); + } + + return result; + } +} diff --git a/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs new file mode 100644 index 0000000000..fc2d7ae1a8 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs @@ -0,0 +1,297 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +internal static partial class TestProjectWriter +{ + private const string GitPropertiesBuildRelativePath = "src/Management/src/GitProperties.Build"; + + private const string HelloWorldSource = """ + Console.WriteLine("Hello, World!"); + """; + + private const string NonZeroExitSource = """ + using System.Diagnostics.CodeAnalysis; + + [assembly: ExcludeFromCodeCoverage] + + return 1; + """; + + private static readonly string[] SharedBuildInfrastructureFiles = + [ + "shared.props", + "shared-package.props", + "shared-project.props", + "versions.props", + "stylecop.json", + "PackageIcon.png", + "PackageReadme.md", + "Steeltoe.Debug.ruleset", + "Steeltoe.Release.ruleset" + ]; + + private static readonly Task RepositoryRootTask = ResolveRepositoryRootAsync(); + + private static async Task CopyGitPropertiesBuildSourceAsync(string destinationDirectory) + { + string basePath = Path.Combine(destinationDirectory, GitPropertiesBuildRelativePath); + Directory.CreateDirectory(Path.Combine(basePath, "build")); + + string projectFile = await GetGitPropertiesBuildProjectFileAsync(); + string targetsFile = await GetTargetsFileAsync(); + string markerFile = await GetSourceCheckoutMarkerFileAsync(); + string buildDirectory = await GetGitPropertiesBuildDirectoryAsync(); + + File.Copy(projectFile, Path.Combine(basePath, Path.GetFileName(projectFile)), true); + File.Copy(targetsFile, Path.Combine(basePath, "build", Path.GetFileName(targetsFile)), true); + File.Copy(markerFile, Path.Combine(basePath, Path.GetFileName(markerFile)), true); + + foreach (string sourceFile in Directory.GetFiles(buildDirectory, "*.cs")) + { + File.Copy(sourceFile, Path.Combine(basePath, Path.GetFileName(sourceFile)), true); + } + } + + private static async Task CopySharedBuildInfrastructureAsync(string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + string repositoryRoot = await RepositoryRootTask; + + foreach (string fileName in SharedBuildInfrastructureFiles) + { + File.Copy(Path.Combine(repositoryRoot, fileName), Path.Combine(destinationDirectory, fileName), true); + } + } + + private static async Task GetGitPropertiesBuildDirectoryAsync() + { + string repositoryRoot = await RepositoryRootTask; + return Path.Combine(repositoryRoot, "src", "Management", "src", "GitProperties.Build"); + } + + private static async Task GetGitPropertiesBuildProjectFileAsync() + { + string directory = await GetGitPropertiesBuildDirectoryAsync(); + return Path.Combine(directory, "Steeltoe.Management.GitProperties.Build.csproj"); + } + + private static async Task GetTargetsFileAsync() + { + string directory = await GetGitPropertiesBuildDirectoryAsync(); + return Path.Combine(directory, "build", "Steeltoe.Management.GitProperties.Build.targets"); + } + + private static async Task GetSourceCheckoutMarkerFileAsync() + { + string directory = await GetGitPropertiesBuildDirectoryAsync(); + return Path.Combine(directory, "SourceCheckout.txt"); + } + + public static async Task GetPackageIdAsync() + { + string projectFile = await GetGitPropertiesBuildProjectFileAsync(); + return Path.GetFileNameWithoutExtension(projectFile); + } + + private static async Task GetGitPropertiesBuildTargetFrameworkAsync() + { + string projectFile = await GetGitPropertiesBuildProjectFileAsync(); + string projectContent = await File.ReadAllTextAsync(projectFile, TestContext.Current.CancellationToken); + Match match = TargetFrameworkRegex().Match(projectContent); + + if (!match.Success) + { + throw new InvalidOperationException($"Could not find in {projectFile}."); + } + + return match.Groups[1].Value; + } + + private static async Task ResolveRepositoryRootAsync([CallerFilePath] string sourceFilePath = "") + { + string sourceDirectory = Path.GetDirectoryName(sourceFilePath) ?? throw new InvalidOperationException("Could not determine the test source directory."); + string output = await ProcessRunner.RunGitAsync(sourceDirectory, CancellationToken.None, "rev-parse", "--show-toplevel"); + return output.Trim().Replace('/', Path.DirectorySeparatorChar); + } + + public static async Task WriteAppProjectAsync(string destinationDirectory, string projectName, IEnumerable? targetFrameworks = null, + bool? generateGitProperties = true, string? extraItemGroupContent = null) + { + string appDirectory = Path.Combine(destinationDirectory, projectName); + Directory.CreateDirectory(appDirectory); + + string targetFrameworkElement = targetFrameworks == null + ? $"{TestAppTargetFramework.Default}" + : $"{string.Join(';', targetFrameworks)}"; + + string generateGitPropertiesElement = string.Empty; + + if (generateGitProperties != null) + { + string generateGitPropertiesValue = generateGitProperties.Value ? "true" : "false"; + generateGitPropertiesElement = $"{generateGitPropertiesValue}"; + } + + string projectFile = await GetGitPropertiesBuildProjectFileAsync(); + string targetsFile = await GetTargetsFileAsync(); + + string projectContent = $""" + + + Exe + {targetFrameworkElement} + {generateGitPropertiesElement} + enable + enable + + + + + false + + {extraItemGroupContent} + + + + + """; + + await File.WriteAllTextAsync(Path.Combine(appDirectory, $"{projectName}.csproj"), projectContent, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(appDirectory, "Program.cs"), HelloWorldSource, TestContext.Current.CancellationToken); + + return appDirectory; + } + + public static async Task WriteFakeGitExecutableProjectAsync(string destinationDirectory, string projectName, string versionOutput) + { + string projectDirectory = Path.Combine(destinationDirectory, projectName); + Directory.CreateDirectory(projectDirectory); + + string projectContent = $""" + + + Exe + {TestAppTargetFramework.Default} + enable + + + """; + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), projectContent, TestContext.Current.CancellationToken); + + string printVersionSource = $""" + using System.Diagnostics.CodeAnalysis; + + [assembly: ExcludeFromCodeCoverage] + + Console.WriteLine("{versionOutput}"); + """; + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), printVersionSource, TestContext.Current.CancellationToken); + + return projectDirectory; + } + + public static async Task WriteNonZeroExitCodeGitExecutableProjectAsync(string projectDirectory, string projectName) + { + Directory.CreateDirectory(projectDirectory); + + string projectContent = $""" + + + Exe + {TestAppTargetFramework.Default} + enable + + + """; + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), projectContent, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), NonZeroExitSource, TestContext.Current.CancellationToken); + } + + public static async Task WriteDummyDependencyProjectAsync(string destinationDirectory, string projectName) + { + string projectDirectory = Path.Combine(destinationDirectory, projectName); + Directory.CreateDirectory(projectDirectory); + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), $""" + + + {TestAppTargetFramework.Default} + + + """, TestContext.Current.CancellationToken); + + return projectDirectory; + } + + public static async Task CopyCurrentProjectFilesAsync(string destination) + { + await CopySharedBuildInfrastructureAsync(destination); + await CopyGitPropertiesBuildSourceAsync(destination); + return await WriteAppProjectAsync(destination, GitPropertiesTestWorkspace.TestAppProjectName); + } + + public static async Task PackGitPropertiesBuildToFeedAsync(string workspaceRootDirectory) + { + string packSourceDirectory = Path.Combine(workspaceRootDirectory, "pack-source"); + await CopySharedBuildInfrastructureAsync(packSourceDirectory); + await CopyGitPropertiesBuildSourceAsync(packSourceDirectory); + + string projectDirectory = Path.Combine(packSourceDirectory, GitPropertiesBuildRelativePath); + string projectFile = await GetGitPropertiesBuildProjectFileAsync(); + + await ProcessRunner.RunDotNetAsync(projectDirectory, 0, null, "build", Path.GetFileName(projectFile), "-c", "Release"); + + string targetFramework = await GetGitPropertiesBuildTargetFrameworkAsync(); + return Path.Combine(projectDirectory, "bin", "tasks", targetFramework); + } + + public static async Task WriteNuGetConfigAsync(string filePath, string feedDirectory) + { + string content = $""" + + + + + + + + """; + + await File.WriteAllTextAsync(filePath, content, TestContext.Current.CancellationToken); + } + + public static async Task CreatePackageConsumerProjectAsync(string projectDirectory, string packageVersion) + { + Directory.CreateDirectory(projectDirectory); + + string projectContent = $""" + + + Exe + {TestAppTargetFramework.Default} + enable + true + + + + + + + """; + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), HelloWorldSource, TestContext.Current.CancellationToken); + } + + [GeneratedRegex("(.+?)")] + private static partial Regex TargetFrameworkRegex(); +} diff --git a/src/Steeltoe.All.slnx b/src/Steeltoe.All.slnx index dbe0f1cafb..081dcf36d5 100644 --- a/src/Steeltoe.All.slnx +++ b/src/Steeltoe.All.slnx @@ -70,10 +70,12 @@ + + diff --git a/src/Steeltoe.All.slnx.DotSettings b/src/Steeltoe.All.slnx.DotSettings index 6ce837b23e..59fd89d948 100644 --- a/src/Steeltoe.All.slnx.DotSettings +++ b/src/Steeltoe.All.slnx.DotSettings @@ -93,6 +93,8 @@ SUGGESTION WARNING SUGGESTION + DO_NOT_SHOW + DO_NOT_SHOW HINT WARNING WARNING diff --git a/src/Steeltoe.Management.slnf b/src/Steeltoe.Management.slnf index 121eea921c..d50ea1b7cf 100644 --- a/src/Steeltoe.Management.slnf +++ b/src/Steeltoe.Management.slnf @@ -16,10 +16,12 @@ "Logging\\src\\DynamicSerilog\\Steeltoe.Logging.DynamicSerilog.csproj", "Management\\src\\Abstractions\\Steeltoe.Management.Abstractions.csproj", "Management\\src\\Endpoint\\Steeltoe.Management.Endpoint.csproj", + "Management\\src\\GitProperties.Build\\Steeltoe.Management.GitProperties.Build.csproj", "Management\\src\\Prometheus\\Steeltoe.Management.Prometheus.csproj", "Management\\src\\Tasks\\Steeltoe.Management.Tasks.csproj", "Management\\src\\Tracing\\Steeltoe.Management.Tracing.csproj", "Management\\test\\Endpoint.Test\\Steeltoe.Management.Endpoint.Test.csproj", + "Management\\test\\GitProperties.Build.Test\\Steeltoe.Management.GitProperties.Build.Test.csproj", "Management\\test\\Prometheus.Test\\Steeltoe.Management.Prometheus.Test.csproj", "Management\\test\\RazorPagesTestWebApp\\Steeltoe.Management.Endpoint.RazorPagesTestWebApp.csproj", "Management\\test\\Tasks.Test\\Steeltoe.Management.Tasks.Test.csproj", diff --git a/versions.props b/versions.props index a664229a7f..618acfc5e9 100644 --- a/versions.props +++ b/versions.props @@ -9,6 +9,7 @@ 10.0.* 7.2.* 3.58.* + 18.7.* 5.0.* 7.0.* 7.0.* diff --git a/view-coverage.ps1 b/view-coverage.ps1 new file mode 100644 index 0000000000..71aff183b3 --- /dev/null +++ b/view-coverage.ps1 @@ -0,0 +1,66 @@ +<# +.SYNOPSIS + Collects coverage for GitProperties.Build.Test and renders a line-by-line HTML report. + +.DESCRIPTION + Mirrors what CI does (dotnet-coverage wraps the whole dotnet build/publish subprocess tree spawned by the + tests, then normalize-coverage-paths.ps1 rewrites ephemeral temp paths and merges duplicate subprocess + entries), then renders the result with ReportGenerator for local inspection. + + Each run writes into its own timestamped directory under /coveragereport/, so you can keep + several runs around and diff them against each other later. + + One-time setup (not done by this script): + dotnet tool install --global dotnet-reportgenerator-globaltool + dotnet tool restore + +.PARAMETER OutputBasePath + Base directory for test output and the generated report. Defaults to "C:\Temp". + +.PARAMETER TargetDir + Directory to write the HTML report into. Defaults to "/coveragereport/". + +.EXAMPLE + ./view-coverage.ps1 + +.EXAMPLE + ./view-coverage.ps1 -OutputBasePath D:\CoverageRuns + +.EXAMPLE + ./view-coverage.ps1 -TargetDir coveragereport/baseline +#> +param( + [string]$OutputBasePath = "C:\Temp", + [string]$TargetDir = (Join-Path $OutputBasePath "coveragereport_$(Get-Date -Format 'yyyy-MM-dd_HHmmss')") +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +$repoRoot = $PSScriptRoot +$testProject = Join-Path $repoRoot "src/Management/test/GitProperties.Build.Test" +$testOutput = Join-Path $OutputBasePath "TestOutput" +$coverageFile = Join-Path $testOutput "GitProperties.Build.Test.cobertura.xml" + +New-Item -ItemType Directory -Force -Path $testOutput | Out-Null + +Write-Host "==> Building test project" -ForegroundColor Cyan +dotnet build $testProject -c Release + +Write-Host "==> Collecting coverage (this spawns real dotnet build/publish subprocesses, can take a minute or two)" -ForegroundColor Cyan +dotnet-coverage collect -f cobertura -o $coverageFile -- ` + dotnet test $testProject --no-build --configuration Release ` + --logger trx --results-directory $testOutput + +Write-Host "==> Normalizing temp paths and merging duplicate subprocess entries" -ForegroundColor Cyan +& (Join-Path $repoRoot ".github/scripts/normalize-coverage-paths.ps1") ` + -CoverageFile $coverageFile ` + -RepoRoot $repoRoot ` + -ProjectRelativePath "src/Management/src/GitProperties.Build" + +Write-Host "==> Generating HTML report" -ForegroundColor Cyan +reportgenerator -reports:$coverageFile -targetdir:$TargetDir -reporttypes:Html -filefilters:"-*.g.cs" + +$indexPath = [System.IO.Path]::GetFullPath((Join-Path $TargetDir "index.html")) +$indexUri = ([System.Uri]$indexPath).AbsoluteUri +Write-Host "==> Report ready: $indexUri" -ForegroundColor Cyan