From ab637a0e234e1ca1d2912119c94ed26d27c32f16 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:28:58 +0200 Subject: [PATCH 01/20] --wip --- Directory.Build.targets | 7 +- .../Info/Contributors/GitInfoContributor.cs | 26 +- .../ComposeGitPropertiesTask.cs | 119 +++ .../DetectConsumingPackageReferenceTask.cs | 95 ++ .../FindGitRepositoryRootTask.cs | 80 ++ .../GenerateGitPropertiesCacheTask.cs | 665 +++++++++++++ .../GitProperties.Build/GitProcessRunner.cs | 54 ++ .../GitPropertiesFileWriter.cs | 117 +++ .../src/GitProperties.Build/PackageReadme.md | 105 +++ .../GitProperties.Build/SourceCheckout.txt | 11 + ...ltoe.Management.GitProperties.Build.csproj | 95 ++ ...toe.Management.GitProperties.Build.targets | 378 ++++++++ .../Contributors/GitInfoContributorTest.cs | 72 ++ .../GitPropertiesBuildTests.cs | 886 ++++++++++++++++++ .../GitPropertiesTestWorkspace.cs | 393 ++++++++ .../GitProperties.Build.Test/ProcessResult.cs | 7 + .../GitProperties.Build.Test/ProcessRunner.cs | 108 +++ .../PropertiesFile.cs | 39 + ...Management.GitProperties.Build.Test.csproj | 19 + .../GitProperties.Build.Test/TestPaths.cs | 130 +++ src/Steeltoe.All.slnx | 2 + src/Steeltoe.All.slnx.DotSettings | 2 + src/Steeltoe.Management.slnf | 2 + versions.props | 1 + 24 files changed, 3409 insertions(+), 4 deletions(-) create mode 100644 src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs create mode 100644 src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs create mode 100644 src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs create mode 100644 src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs create mode 100644 src/Management/src/GitProperties.Build/GitProcessRunner.cs create mode 100644 src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs create mode 100644 src/Management/src/GitProperties.Build/PackageReadme.md create mode 100644 src/Management/src/GitProperties.Build/SourceCheckout.txt create mode 100644 src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj create mode 100644 src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets create mode 100644 src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs create mode 100644 src/Management/test/GitProperties.Build.Test/ProcessResult.cs create mode 100644 src/Management/test/GitProperties.Build.Test/ProcessRunner.cs create mode 100644 src/Management/test/GitProperties.Build.Test/PropertiesFile.cs create mode 100644 src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj create mode 100644 src/Management/test/GitProperties.Build.Test/TestPaths.cs 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 - + 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/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs new file mode 100644 index 0000000000..bc4d96e461 --- /dev/null +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -0,0 +1,119 @@ +// 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 cache (see ) with the fields that can never be cached across projects: the live +/// working-tree dirty state, the per-project $(Version), and the current build's own timestamp. Runs once per project, every build - unlike the cache +/// generation, this is deliberately not skippable via Inputs/Outputs, since 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 by a second build. +/// +// 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 same composed content to - the durable fallback file that + /// Steeltoe.Management.GitProperties.Build.targets' IncludeGitPropertiesInOutput target later falls back to 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 by default). Empty (the default) is a no-op; only non-empty when the + /// consumer opted into $(GitPropertiesWriteToProjectDirectory). + /// + public string? FallbackFile { get; set; } + + /// + public override bool Execute() + { + int exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, "status --porcelain", out string stdout, out string stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to determine working tree status: {0}", stderr); + return false; + } + + bool isDirty = stdout.Length > 0; + List lines = File.ReadAllLines(CacheFile).ToList(); + + for (int index = 0; index < lines.Count; index++) + { + if (isDirty && lines[index].StartsWith($"{GitPropertiesFileWriter.CommitIdDescribeKey}=", StringComparison.Ordinal)) + { + lines[index] += "-dirty"; + } + } + + lines.Add($"git.dirty={(isDirty ? "true" : "false")}"); + lines.Add($"git.build.version={GitPropertiesFileWriter.EscapeLineBreaks(Version)}"); + + // S6354 (use an injectable time provider): not practical here, for the same reason as + // GitPropertiesFileWriter.TryAcquireExclusiveLock - see that method's remarks. Matches the + // ISO-8601-with-offset style git itself uses for git.commit.time (%cI), rather than + // normalizing to UTC - this is "when this build ran, in the build machine's own local time", + // not a value that needs to compare directly 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}"); + + try + { + GitPropertiesFileWriter.WriteAtomic(OutputFile, lines); + } + catch (IOException exception) + { + Log.LogError($"git.properties: failed to write {OutputFile}: {exception.Message}"); + return false; + } + + if (FallbackFile is null or "") + { + return true; + } + + try + { + GitPropertiesFileWriter.WriteAtomic(FallbackFile, lines); + } + catch (IOException exception) + { + Log.LogError($"git.properties: failed to write fallback file {FallbackFile}: {exception.Message}"); + return false; + } + + return true; + } +} diff --git a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs new file mode 100644 index 0000000000..b576d6b533 --- /dev/null +++ b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs @@ -0,0 +1,95 @@ +// 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; + +/// +/// Determines whether this project's own fully-resolved dependency graph includes any of - used to smart-default +/// $(GenerateGitProperties) so the vast majority of projects in a large solution (class libraries, test projects, anything that can't possibly expose an +/// actuator) skip generation entirely, without requiring each of them to opt out individually. +/// +/// +/// Reads (project.assets.json) rather than this project's own @(PackageReference) items deliberately: NuGet flattens +/// the *entire* transitive graph - through both PackageReference and ProjectReference chains - into every consuming project's own assets file, the same +/// mechanism that already lets a shared base library's own dependencies "just work" for whoever references it, without redeclaring them. That means this +/// also correctly detects the common pattern of a shared library wrapping actuator registration on behalf of many host apps, as long as that library's +/// own reference isn't PrivateAssets="All" - which would also strip the actuator assembly from every host's own runtime output, breaking the feature +/// outright, so it's not a viable pattern for a library that actually activates actuators in the first place. The file is written by a prior, separate +/// restore pass (implicit or explicit) - never by a target within the current build - so there's no ordering dependency on any of our own targets/hooks: +/// it's either already on disk with the final, fully-resolved graph, or it doesn't exist yet (a fresh clone with no restore at all), in which case 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], unlike every other string parameter on tasks in this project: MSBuild's required-parameter check treats an empty string + /// the same as "not supplied" at all, which would turn $(GitPropertiesConsumingPackageIds) explicitly set to blank (e.g. via + /// "-p:GitPropertiesConsumingPackageIds=") into a build error instead of the well-defined, graceful "no package ID ever matches" outcome + /// already produces for it. + /// + 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() + { + bool hasReference = false; + + if (ProjectAssetsFile.Length > 0 && File.Exists(ProjectAssetsFile)) + { + try + { + string content = File.ReadAllText(ProjectAssetsFile); + hasReference = ContainsAnyPackage(content); + } + catch + { + hasReference = false; + } + } + + HasReference = hasReference; + return true; + } + + /// + /// A plain substring search for the quoted package ID plus a trailing slash - the shape every "libraries" entry key in project.assets.json takes + /// ("PackageId/Version") - rather than a full JSON parse. Deliberately not scoped to the "libraries" object specifically, and deliberately not guarding + /// against a configured ID that happens to collide with an unrelated NuGet content-folder name (e.g. "lib", "tools", "analyzers", which show up as path + /// prefixes inside every package's own file list): Steeltoe's own consumers are enterprise teams that namespace-qualify their packages (e.g. + /// "Contoso.Actuators"), so a configured ID colliding with a short, generic folder name isn't a realistic scenario worth the complexity of a bounded + /// parse to rule out. + /// + private bool ContainsAnyPackage(string assetsFileContent) + { + foreach (string rawPackageId in PackageIds.Split(';')) + { + string packageId = rawPackageId.Trim(); + + 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..9f911dda9c --- /dev/null +++ b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.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; + +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 - full worktree/submodule support is out of scope. +/// +// 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 unsupportedGitFile = false; + + try + { + 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)) + { + unsupportedGitFile = true; + break; + } + + current = current.Parent; + } + } + catch + { + repositoryRoot = string.Empty; + unsupportedGitFile = false; + } + + RepositoryRoot = repositoryRoot; + IsUnsupportedGitFile = unsupportedGitFile; + return true; + } +} diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs new file mode 100644 index 0000000000..acb219e5d1 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -0,0 +1,665 @@ +// 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; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Steeltoe.Management.GitProperties.Build; + +/// +/// Computes the 17 git.properties fields that are stable across the whole repository (everything except the live working-tree state, the per-project +/// build version, and the current build's own timestamp - see ) and writes them to a shared cache file. Meant to +/// run at most once per solution build: callers key their own Inputs/Outputs incrementality off this task's output so 49 of 50 +/// projects in a solution reuse it instead of re-invoking git. +/// +/// +/// Forgivable, EnableWarnings-gated situations each get their own diagnostic code so they can be suppressed individually (e.g. via +/// $(MSBuildWarningsAsErrors)/$(NoWarn)) or all at once via EnableWarnings=false: GITPROPS001 (no usable git working tree - the other half of this code, +/// "no .git directory at all", is raised from Steeltoe.Management.GitProperties.Build.targets before this task ever runs), GITPROPS003 (git executable +/// not found/runnable), GITPROPS004 (installed git predates ), GITPROPS005 (repository has zero commits), GITPROPS006 (a +/// shallow clone - unlike the other four, generation still succeeds, just with two fields left empty). GITPROPS002 (".git" is a file, i.e. a +/// worktree/submodule) is also raised from the .targets file, since it's detected before this task runs. +/// +// ReSharper disable once UnusedType.Global +public sealed class GenerateGitPropertiesCacheTask : Task +{ + /// + /// The oldest git version this task is known to work against. Set by "rev-parse --is-shallow-repository" below (see + /// ) - the newest feature this task relies on, added in git 2.15.0 (released 2017-10-30). Every other git command + /// used here (e.g. "tag --points-at", the "%cI" strict-ISO-8601 pretty-format placeholder) requires an older version than that, so this one flag alone + /// determines the actual floor. + /// + private static readonly Version MinimumGitVersion = new(2, 15, 0); + + /// + /// 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)); + + /// + /// The field separator ("%x1f", ASCII Unit Separator) used in 's own "--pretty=format:" string - a single-element + /// array only because requires one, not because there's more than one separator. + /// + private static readonly char[] CommitLogFieldSeparator = [(char)0x1F]; + + /// + /// Line separators for splitting raw git command output in - both are needed since git's own output uses "\n" + /// (see 's remarks), but a "git config" value or similar could still legitimately contain a literal "\r". + /// + private static readonly char[] LineSeparators = + [ + '\r', + '\n' + ]; + + /// + /// CI-provided environment variables consulted by when HEAD is detached - most CI systems check out a specific commit + /// rather than a branch, so "git rev-parse --abbrev-ref HEAD" alone reports "HEAD", not the branch a human would recognize. + /// + 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; } + + /// + public override bool Execute() + { + GitVersionStatus versionStatus = CheckGitVersion(); + + if (versionStatus == GitVersionStatus.Unknown) + { + return false; + } + + if (versionStatus == GitVersionStatus.Incompatible) + { + return true; + } + + string? commitId = Preflight(); + + if (commitId == null) + { + return true; + } + + return TryGenerateAndWriteCache(commitId); + } + + /// + /// Checks the installed git against - deliberately the very first git invocation this task makes, before relying on any + /// command a genuinely old git might reject outright (e.g. "rev-parse --is-shallow-repository" in would fail + /// with "unknown option" on one). + /// + /// + /// The "too old"/"unparseable" paths are both untested against a real git binary, for the same reason the git-not-runnable-at-all case always has been: + /// reliably faking a fully working-but-old (or malformed-version) git installation across Windows/Linux/macOS in this suite's plain "spawn a real dotnet + /// build" test style isn't practical. + /// + private GitVersionStatus CheckGitVersion() + { + string? output = GetGitVersion(); + + if (output == null) + { + return GitVersionStatus.Incompatible; + } + + Version? installedVersion = ParseGitVersion(output); + + if (installedVersion == null) + { + Log.LogError($"git.properties: could not parse the installed git version from '{GitExecutable} --version' output: '{output}'."); + return GitVersionStatus.Unknown; + } + + if (installedVersion < MinimumGitVersion) + { + ReportDiagnostic("GITPROPS004", + $"git.properties generation skipped: installed git version {installedVersion} is older than the minimum supported version " + + $"({MinimumGitVersion}). Upgrade git to resolve this."); + + return GitVersionStatus.Incompatible; + } + + return GitVersionStatus.Compatible; + } + + /// + /// Runs "git --version", reporting GITPROPS003 (forgivable) if git can't be invoked at all - either the process fails to start, or it starts and exits + /// with a non-zero code. Returns the raw output on success, or null in either failure case. + /// + private string? GetGitVersion() + { + string output; + int exitCode; + + try + { + exitCode = RunGit("--version", out output, out _); + } + catch (Exception exception) + { + ReportDiagnostic("GITPROPS003", $"git.properties generation skipped: could not run '{GitExecutable}' ({exception.Message})."); + return null; + } + + if (exitCode != 0) + { + ReportDiagnostic("GITPROPS003", $"git.properties generation skipped: '{GitExecutable} --version' exited with code {exitCode}."); + return null; + } + + return output; + } + + /// + /// 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. Pure string + /// parsing, no I/O and no MSBuild logging - deliberately kept separate from so this part alone could be unit-tested without + /// a real git process, if this project ever adds that kind of test. + /// + private 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); + int build = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0; + + return new Version(major, minor, build); + } + + /// + /// Forgivable checks: the repository must be a usable work tree, and it must have at least one commit - git's own runnability and version were already + /// checked in , before this ever runs. Returns null when either check fails - the anomaly is already reported via + /// (not as a build error), and generation simply skips, per the class remarks. Returns the resolved commit ID otherwise. + /// + private string? Preflight() + { + int exitCode = RunGit("rev-parse --is-inside-work-tree", out string stdout, out _); + + if (exitCode != 0 || stdout != "true") + { + ReportDiagnostic("GITPROPS001", $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git working tree."); + return null; + } + + exitCode = RunGit("rev-parse HEAD", out stdout, out _); + + if (exitCode != 0) + { + ReportDiagnostic("GITPROPS005", "git.properties generation skipped: repository has no commits yet."); + return null; + } + + return stdout; + } + + /// + /// From here on, any failure is unexpected and fatal - git and the repository are already known-good (see ). + /// + /// + /// Wrapped in a cross-process lock (see ): MSBuild's own Inputs/Outputs staleness check + /// (in Steeltoe.Management.GitProperties.Build.targets) runs independently per project/TFM, with no coordination between them - so when multiple + /// projects or TFMs of the same multi-targeted project build concurrently (MSBuild's default), more than one can see the shared cache as stale and + /// decide to invoke this task at the same time, before either has written a fresh one. Deliberately does NOT try to validate whether the existing cache + /// content is itself still correct/up to date - that's the target-level Inputs/Outputs check's job, and it has already decided we need to run. The only + /// thing checked here is whether the file was rewritten by someone else WHILE this call was waiting for the lock: only then is skipping safe, because a + /// concurrent write during that narrow window must be reacting to the exact same staleness trigger, in the same build, against the same repository + /// state. (An early, wrong attempt at this compared the cache's stored commit ID to the current one - but tagging an existing commit, for example, + /// invalidates the cache without changing the commit ID, so that check silently skipped writes that were actually needed.) This is purely a "thundering + /// herd" optimization, not a correctness fix - already guarantees no reader ever observes a + /// torn/partial file even without it, so failing to acquire the lock (or an environment where locking isn't possible at all) safely falls back to doing + /// the work anyway, same as before this existed. + /// + private bool TryGenerateAndWriteCache(string commitId) + { + DateTime? cacheWriteTimeBeforeLock = File.Exists(CacheFile) ? File.GetLastWriteTimeUtc(CacheFile) : null; + + // The ".lock" file itself is deliberately never deleted, only closed (releasing the OS-level lock, not the + // file) - it lives next to $(GitPropertiesCacheFile) under obj\_GitProperties\, an already-gitignored, + // `dotnet clean`-swept intermediate directory, so leaving it there costs nothing. Deleting it after use + // would actually be less safe: a concurrent builder could open (or create) the very same path a moment + // later, and "delete, then someone else recreates the same path" is the classic TOCTOU race that breaks a + // file-based mutex's mutual exclusion guarantee - simplest to just never delete it and let every build reuse + // the same, already-existing lock file. + using FileStream? cacheLock = GitPropertiesFileWriter.TryAcquireExclusiveLock($"{CacheFile}.lock", TimeSpan.FromSeconds(30)); + + if (cacheLock != null && 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; + } + + Log.LogMessage("git.properties: generating shared cache at '{0}'.", CacheFile); + + int exitCode = RunGit("rev-parse --is-shallow-repository", out string stdout, out string stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to determine shallow-clone status: {0}", stderr); + return false; + } + + bool isShallow = stdout == "true"; + + if (isShallow) + { + ReportDiagnostic("GITPROPS006", + "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) + { + return false; + } + + TagDescription tagDescription = DescribeClosestTag(isShallow); + TagsAndCommitCount? tagsAndCommitCount = ReadTagsAndTotalCommitCount(isShallow); + + if (tagsAndCommitCount == null) + { + return false; + } + + GitConfig? config = ReadConfig(); + + if (config == null) + { + return false; + } + + string branch = ResolveBranch(); + string buildHost = Environment.MachineName; + + List lines = + [ + $"git.branch={GitPropertiesFileWriter.EscapeLineBreaks(branch)}", + $"git.commit.id={GitPropertiesFileWriter.EscapeLineBreaks(commitId)}", + $"git.commit.id.abbrev={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.AbbrevId)}", + $"{GitPropertiesFileWriter.CommitIdDescribeKey}={GitPropertiesFileWriter.EscapeLineBreaks(tagDescription.BaseDescribe)}", + $"git.commit.time={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.CommitTime)}", + $"git.commit.message.short={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.ShortMessage)}", + $"git.commit.message.full={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.FullMessage)}", + $"git.commit.user.name={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.AuthorName)}", + $"git.commit.user.email={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.AuthorEmail)}", + $"git.build.host={GitPropertiesFileWriter.EscapeLineBreaks(buildHost)}", + $"git.build.user.name={GitPropertiesFileWriter.EscapeLineBreaks(config.UserName)}", + $"git.build.user.email={GitPropertiesFileWriter.EscapeLineBreaks(config.UserEmail)}", + $"git.tags={GitPropertiesFileWriter.EscapeLineBreaks(tagsAndCommitCount.Tags)}", + $"git.closest.tag.name={GitPropertiesFileWriter.EscapeLineBreaks(tagDescription.ClosestTagName)}", + $"git.closest.tag.commit.count={GitPropertiesFileWriter.EscapeLineBreaks(tagDescription.ClosestTagCommitCount)}", + $"git.remote.origin.url={GitPropertiesFileWriter.EscapeLineBreaks(config.RemoteUrl)}", + $"git.total.commit.count={GitPropertiesFileWriter.EscapeLineBreaks(tagsAndCommitCount.TotalCommitCount)}" + ]; + + try + { + GitPropertiesFileWriter.WriteAtomic(CacheFile, lines); + } + catch (IOException exception) + { + Log.LogError($"git.properties: failed to write {CacheFile}: {exception.Message}"); + return false; + } + + return true; + } + + /// + /// See the "Deliberately does NOT try to validate..." remarks on for why this checks only for a rewrite during + /// the lock wait, not the cache's own correctness. + /// + private bool WasCacheRewrittenWhileWaitingForLock(DateTime? writeTimeBeforeLock) + { + if (!File.Exists(CacheFile)) + { + return false; + } + + return writeTimeBeforeLock == null || File.GetLastWriteTimeUtc(CacheFile) > writeTimeBeforeLock.Value; + } + + private CommitLogEntry? GetLatestCommitLogEntry() + { + int exitCode = RunGit($"log -1 --abbrev={CommitIdAbbrevLength} --pretty=format:%h%x1f%an%x1f%ae%x1f%cI%x1f%s%x1f%B", out string stdout, + out string stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to read commit metadata: {0}", stderr); + return null; + } + + 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); + } + + /// + /// Parses the single "describe --tags --long --always" call 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). Failure here is not fatal - it degrades to empty/fallback + /// values, same as "no tags exist". + /// + private TagDescription DescribeClosestTag(bool isShallow) + { + int exitCode = RunGit("describe --tags --long --always", out string stdout, out _); + string baseDescribe = string.Empty; + string closestTagName = string.Empty; + string closestTagCommitCount = string.Empty; + + if (exitCode == 0 && !string.IsNullOrEmpty(stdout)) + { + int lastDashIndex = stdout.LastIndexOf('-'); + int secondLastDash = lastDashIndex >= 0 ? stdout.LastIndexOf('-', lastDashIndex - 1) : -1; + bool hasTagPrefix = lastDashIndex >= 0 && secondLastDash >= 0 && stdout.Substring(lastDashIndex + 1).StartsWith("g", StringComparison.Ordinal); + + if (!hasTagPrefix) + { + // No tags reachable at all - "--always" fallback is a bare abbreviated SHA. + baseDescribe = stdout; + } + else + { + closestTagName = stdout.Substring(0, secondLastDash); + closestTagCommitCount = stdout.Substring(secondLastDash + 1, lastDashIndex - secondLastDash - 1); + baseDescribe = closestTagCommitCount == "0" ? closestTagName : $"{closestTagName}-{closestTagCommitCount}"; + } + } + + if (isShallow) + { + // Ancestry walk is truncated on a shallow clone - a "count" here would be silently wrong. + closestTagCommitCount = string.Empty; + } + + return new TagDescription(baseDescribe, closestTagName, closestTagCommitCount); + } + + private TagsAndCommitCount? ReadTagsAndTotalCommitCount(bool isShallow) + { + int exitCode = RunGit("tag --points-at HEAD", out string stdout, out string stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to list tags: {0}", stderr); + return null; + } + + string tags = string.Join(",", stdout.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries)); + + string totalCommitCount = string.Empty; + + if (!isShallow) + { + exitCode = RunGit("rev-list --count HEAD", out stdout, out stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to count commits: {0}", stderr); + return null; + } + + totalCommitCount = stdout; + } + + return new TagsAndCommitCount(tags, totalCommitCount); + } + + private GitConfig? ReadConfig() + { + int exitCode = RunGit("config --list", out string stdout, out string stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to read git config: {0}", stderr); + return null; + } + + string userName = string.Empty; + string userEmail = string.Empty; + string remoteUrl = string.Empty; + + foreach (string line in stdout.Split('\n')) + { + int equalsIndex = line.IndexOf('='); + + if (equalsIndex < 0) + { + continue; + } + + 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; + } + } + + return new GitConfig(userName, userEmail, StripUserInfo(remoteUrl)); + } + + /// + /// Falls back to common CI environment variables when HEAD is detached (e.g. most CI checkouts). + /// + private string ResolveBranch() + { + string branch = string.Empty; + int exitCode = RunGit("rev-parse --abbrev-ref HEAD", out string stdout, out _); + + if (exitCode == 0) + { + branch = stdout; + } + + if (string.IsNullOrEmpty(branch) || branch == "HEAD") + { + foreach (string name in BranchEnvironmentVariableNames) + { + string? value = Environment.GetEnvironmentVariable(name); + + if (!string.IsNullOrEmpty(value)) + { + branch = value; + break; + } + } + } + + return branch; + } + + private int RunGit(string arguments, out string stdout, out string stderr) + { + return GitProcessRunner.Run(GitExecutable, RepositoryRoot, arguments, out stdout, out stderr); + } + + /// + /// Reports a forgivable anomaly - either a full skip (GITPROPS001/003/004/005, where the caller has already decided to return false) or a + /// degraded-but-successful outcome (GITPROPS006, where generation still proceeds) - as a warning when is true, or a plain + /// informational message otherwise. + /// + /// + /// The downgraded message carries no code at all: a code only has a purpose when something is suppressible (via $(NoWarn)/ $(MSBuildWarningsAsErrors)), + /// which only applies to warnings - attaching one to a plain message just to look consistent added no real value, and an earlier version of this method + /// that manually embedded "{code}: " in the message text on top of also passing it as the structured Log.LogMessage code parameter actually + /// double-printed it (the console logger already renders a message's code automatically when one is supplied). Left at LogMessage's own default + /// importance (Normal), not High: consumers who set EnableWarnings=false have said this is routine and don't want it in their default build output, but + /// it's still one "-v:normal" away if they want to check. + /// + private void ReportDiagnostic(string code, string message) + { + if (EnableWarnings) + { + Log.LogWarning(null, code, null, null, 0, 0, 0, 0, message); + } + else + { + Log.LogMessage(message); + } + } + + private static string StripUserInfo(string url) + { + if (string.IsNullOrEmpty(url)) + { + return 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") - nothing to strip. + } + + return url; + } + + /// + /// Whether the installed git is new enough to use, as determined by . + /// + private enum GitVersionStatus + { + /// + /// Git ran and its version satisfies - safe to proceed. + /// + Compatible, + + /// + /// Git either couldn't be run at all (GITPROPS003) or is older than (GITPROPS004) - both forgivable, already reported + /// via , generation simply skips. + /// + Incompatible, + + /// + /// Git ran, but its "--version" output couldn't be parsed at all - already reported as a hard error via Log.LogError, since unlike "genuinely too old", + /// there's no safe default to fall back to here. + /// + Unknown + } + + /// + /// The fields read from a single "log -1 --pretty=format:..." call - see . + /// + /// + /// A plain class with a primary constructor, not a record: records need "init" accessors under the hood, which require + /// System.Runtime.CompilerServices.IsExternalInit - not present on netstandard2.0 (this project's TargetFramework - see its own csproj remarks for why) + /// and not worth polyfilling just for four small, write-once data holders. + /// + 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; + } + + /// + /// The fields derived from a single "describe --tags --long --always" call - see . + /// + private sealed class TagDescription(string baseDescribe, string closestTagName, string closestTagCommitCount) + { + public string BaseDescribe { get; } = baseDescribe; + public string ClosestTagName { get; } = closestTagName; + public string ClosestTagCommitCount { get; } = closestTagCommitCount; + } + + /// + /// The fields read from "tag --points-at HEAD" and (unless shallow) "rev-list --count HEAD" - see . + /// + private sealed class TagsAndCommitCount(string tags, string totalCommitCount) + { + public string Tags { get; } = tags; + public string TotalCommitCount { get; } = totalCommitCount; + } + + /// + /// The fields read from a single "config --list" call - see . + /// + private 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/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/GitPropertiesFileWriter.cs b/src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs new file mode 100644 index 0000000000..e4a447e6ed --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs @@ -0,0 +1,117 @@ +// 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; + +internal static class GitPropertiesFileWriter +{ + /// + /// The git.properties key that writes and later searches for (to + /// append the "-dirty" suffix) - shared so the two can never silently drift out of sync with each other. + /// + public const string CommitIdDescribeKey = "git.commit.id.describe"; + + /// + /// Collapses real newlines to a literal "\n" so a value can never span multiple physical lines (Steeltoe's GitInfoContributor reads this file with + /// File.ReadAllLinesAsync and would silently truncate a value at the first embedded newline otherwise). Colons are deliberately left unescaped - + /// Steeltoe only unescapes "\:" back to ":", so leaving real colons alone (timestamps, URLs) is what round-trips correctly. + /// + public static string EscapeLineBreaks(string? value) + { + return value is null or "" ? string.Empty : value.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\\n"); + } + + /// + /// Writes to a per-process temp file then atomically moves it into place, matching the pattern MSBuild's own WriteLinesToFile task uses (since change + /// wave 18.3) to avoid a concurrent reader ever observing a torn/partial write when multiple projects in a solution build race to populate the same + /// shared cache file. + /// + public static void WriteAtomic(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); + File.WriteAllText(tempPath, $"{string.Join("\n", lines)}\n", encoding); + + Exception? lastError = null; + + for (int attempt = 1; attempt <= 3; attempt++) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(tempPath, path); + return; + } + catch (IOException exception) + { + lastError = exception; + Thread.Sleep(10); + } + } + + throw new IOException($"Failed to write {path}", lastError); + } + + /// + /// Attempts to acquire exclusive access to (creating it if needed) as a cross-process mutual-exclusion lock, retrying + /// for up to before giving up. Returns null on timeout, or if locking isn't possible at all in the current environment - + /// locking here is purely an optimization (avoiding redundant work when multiple projects/TFMs race to populate the same shared cache file), never a + /// correctness requirement, so any failure to acquire it must fall back to proceeding without one rather than failing the build. + /// + /// + /// Deliberately not a named : cross-process named Mutex/Semaphore support on Unix was added later than on Windows + /// and still carries real caveats (permissions, abandoned-lock semantics). Exclusive file access is a simpler, more universally portable primitive - + /// it's been supported identically on every OS .NET runs on since .NET Core 1.0 (implemented via ordinary POSIX advisory locking on Unix, not an + /// OS-specific IPC primitive), and an "abandoned" lock needs no special handling: if the holding process crashes, the OS releases the file handle - and + /// the lock - automatically on process exit. + /// + public static FileStream? TryAcquireExclusiveLock(string lockFilePath, TimeSpan timeout) + { + string? directory = Path.GetDirectoryName(lockFilePath); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // S6354 (use an injectable time provider): not practical here - System.TimeProvider isn't + // available on netstandard2.0 without adding a package dependency purely for one retry-loop + // deadline in a synchronous MSBuild task assembly that has no other testability/DI pattern. +#pragma warning disable S6354 + DateTime deadlineUtc = DateTime.UtcNow + timeout; +#pragma warning restore S6354 + + while (true) + { + try + { + return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { +#pragma warning disable S6354 + if (DateTime.UtcNow >= deadlineUtc) +#pragma warning restore S6354 + { + return null; + } + + Thread.Sleep(50); + } + } + } +} diff --git a/src/Management/src/GitProperties.Build/PackageReadme.md b/src/Management/src/GitProperties.Build/PackageReadme.md new file mode 100644 index 0000000000..5e7c90f84d --- /dev/null +++ b/src/Management/src/GitProperties.Build/PackageReadme.md @@ -0,0 +1,105 @@ +# 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 working tree 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. | +| `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. | + +## 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. diff --git a/src/Management/src/GitProperties.Build/SourceCheckout.txt b/src/Management/src/GitProperties.Build/SourceCheckout.txt new file mode 100644 index 0000000000..e96a3b74ce --- /dev/null +++ b/src/Management/src/GitProperties.Build/SourceCheckout.txt @@ -0,0 +1,11 @@ +This file exists so build\Steeltoe.Management.GitProperties.Build.targets 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 - see +$(GitPropertiesTaskHost) in that .targets file for why this distinction matters (in-process vs. +out-of-process task loading). + +This file is deliberately excluded from the packed .nupkg (see the item in +Steeltoe.Management.GitProperties.Build.csproj), 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..46e5d52618 --- /dev/null +++ b/src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj @@ -0,0 +1,95 @@ + + + + + netstandard2.0 + Generates a Spring Boot-compatible git.properties file at build time via MSBuild props/targets/tasks. + git;git.properties;actuator;info;MSBuild + true + + false + + + + + + + + + + + + false + bin\tasks\$(TargetFramework)\ + + + + + true + true + + false + + $(NoWarn);NU5128;NU5100 + + + + + + + + + + + + + + + + + + + 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..0c42695850 --- /dev/null +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -0,0 +1,378 @@ + + + + + auto + + Steeltoe.Management.Endpoint + git + 7 + + true + + false + + $(MSBuildProjectDirectory)\git.properties + + 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\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/GitPropertiesBuildTests.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs new file mode 100644 index 0000000000..ee34dfd0c8 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs @@ -0,0 +1,886 @@ +// 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; + +#pragma warning disable S2925 // "Thread.Sleep" should not be used in tests + +namespace Steeltoe.Management.GitProperties.Build.Test; + +/// +/// Automated regression tests for the Steeltoe.Management.GitProperties.Build project (build/Steeltoe.Management.GitProperties.Build.targets plus its +/// compiled MSBuild tasks). Exercises the scenarios that were manually verified while building this feature: ground-truth property values, incremental +/// cache behavior, publish (with and without a prior build), the warn/info/skip diagnostic paths, shallow clones, non-ASCII commit data, and +/// cross-project cache sharing. Every test runs against an isolated temporary workspace containing the CURRENT source of +/// Steeltoe.Management.GitProperties.Build (see GitPropertiesTestWorkspace), not a stale git-tracked copy, so it always exercises whatever is on disk +/// right now. Every git repository a test operates against is a small, synthetic one created from scratch (`git init` plus a handful of manufactured +/// commits) - never a clone of this (large, real) repository - so the suite stays fast. Nothing here touches this repository's own working tree. +/// +public sealed class GitPropertiesBuildTests : IDisposable +{ + private static readonly Regex NuPkgVersionRegex = + new($@"^{Regex.Escape(TestPaths.PackageId)}\.(.+)\.nupkg$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + private readonly GitPropertiesTestWorkspace _workspace = new(); + + [Fact] + public void GroundTruth_AllPropertiesMatchGit() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 3); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "build"); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + properties["git.commit.id.abbrev"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "--short=7", "HEAD")); + properties["git.commit.user.name"].Should().Be(ProcessRunner.GetGitOutput(repository, "log", "-1", "--format=%an")); + properties["git.commit.user.email"].Should().Be(ProcessRunner.GetGitOutput(repository, "log", "-1", "--format=%ae")); + properties["git.commit.message.short"].Should().Be(ProcessRunner.GetGitOutput(repository, "log", "-1", "--format=%s")); + properties["git.total.commit.count"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-list", "--count", "HEAD")); + + bool expectedDirty = ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Length > 0; + 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), "git.build.time must reflect roughly when this build actually ran."); + + 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); + } + + [Fact] + public void IncrementalBuild_CacheSkipsButDirtyStaysLive() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result1, "first build"); + string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); + File.Exists(cacheFile).Should().BeTrue("the cache file should exist after first build."); + DateTime writeTimeBefore = File.GetLastWriteTimeUtc(cacheFile); + + // Ensure a rewritten file would get a detectably different modified-time. + Thread.Sleep(1100); + ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build", "-v:detailed"); + AssertBuildSucceeded(result2, "second build"); + result2.Output.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); + + File.GetLastWriteTimeUtc(cacheFile).Should().Be(writeTimeBefore, "nothing git-relevant changed, so the cache file must not be rewritten."); + + PropertiesFile.Read(DebugGitPropertiesFile(testApp)).Should().ContainKey("git.dirty"); + } + + /// + /// Guards against git.build.time accidentally ending up in the shared, cross-project/cross-TFM cache (see GenerateGitPropertiesCacheTask) instead of + /// being recomputed by ComposeGitPropertiesTask on every build - same class of regression IncrementalBuild_CacheSkipsButDirtyStaysLive guards against + /// for git.dirty. A cached build time would go stale (reporting the FIRST build's time on every subsequent one), silently defeating the whole point of + /// the field: telling you when THIS build actually ran. + /// + [Fact] + public void BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result1, "first build"); + Dictionary propertiesBefore = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + + // Ensure a live-recomputed build time would get a detectably different value. + Thread.Sleep(1100); + ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result2, "second build"); + Dictionary propertiesAfter = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + + propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"], + "git.build.time must be recomputed on every build, not reused from the shared cache."); + + propertiesAfter["git.commit.time"].Should().Be(propertiesBefore["git.commit.time"], + "git.commit.time must stay tied to the (unchanged) commit, unlike git.build.time."); + + propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"], "nothing about the commit itself changed between the two builds."); + } + + [Fact] + public void NewTag_InvalidatesCache() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result1, "first build"); + Dictionary propertiesBefore = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + propertiesBefore["git.tags"].Should().BeEmpty(); + + ProcessResult tagResult = ProcessRunner.RunGit(repository, "tag", "v1.0.0"); + tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); + + ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result2, "second build"); + Dictionary propertiesAfter = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + propertiesAfter["git.tags"].Should().Be("v1.0.0"); + propertiesAfter["git.commit.id.describe"].Should().Be("v1.0.0"); + } + + /// + /// Unlike every GITPROPS0xx diagnostic (all either $(GitPropertiesEnableWarnings)-gated or left at Message's own default importance because they + /// describe an anomaly, not the happy path), confirmation that git.properties was actually written is unconditional and at High importance - visible in + /// default build output with no extra verbosity flag needed - because it's the one concrete artifact this whole feature exists to produce. Logged from + /// Steeltoe.Management.GitProperties.Build.targets (via $(MSBuildProjectName)), not from ComposeGitPropertiesTask itself - a Task has no built-in notion + /// of "which project is this", so a solution build with many projects would otherwise be unable to tell which one this line belongs to. + /// + [Fact] + public void ComposeGitProperties_LogsWrittenFileAtDefaultVerbosity() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "build"); + + string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + result.Output.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + } + + [Fact] + public void Publish_IncludesGitProperties() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "publish"); + AssertBuildSucceeded(result, "publish"); + result.Output.Should().NotContain("duplicate"); + + Dictionary properties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(testApp)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + } + + [Fact] + public void Publish_NoBuild_IncludesGitProperties() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult buildResult = ProcessRunner.RunDotnet(testApp, "build", "-c", "Release"); + AssertBuildSucceeded(buildResult, "build"); + + ProcessResult publishResult = ProcessRunner.RunDotnet(testApp, "publish", "-c", "Release", "--no-build"); + AssertBuildSucceeded(publishResult, "publish --no-build"); + publishResult.Output.Should().NotContain("duplicate"); + + Dictionary properties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(testApp)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + } + + [Fact] + public void NoGit_WarnsByDefault() + { + string projectDirectory = Path.Combine(_workspace.RootDirectory, "proj"); + Directory.CreateDirectory(projectDirectory); + string testApp = _workspace.CopyCurrentProjectFiles(projectDirectory); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "the build with no .git present"); + AssertWarned(result, "GITPROPS001"); + AssertNoGitPropertiesGenerated(testApp); + } + + [Fact] + public void NoGit_InfoWhenEnableWarningsFalse() + { + string projectDirectory = Path.Combine(_workspace.RootDirectory, "proj"); + Directory.CreateDirectory(projectDirectory); + string testApp = _workspace.CopyCurrentProjectFiles(projectDirectory); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertBuildSucceeded(result, "build"); + AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); + AssertNoGitPropertiesGenerated(testApp); + } + + [Fact] + public void GitFile_WarnsByDefault() + { + string projectDirectory = Path.Combine(_workspace.RootDirectory, "proj"); + Directory.CreateDirectory(projectDirectory); + string testApp = _workspace.CopyCurrentProjectFiles(projectDirectory); + // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk + // (which starts at TestApp, the project actually being built) to find it - i.e. at + // projectDirectory itself. + File.WriteAllText(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj"); + + ProcessResult defaultResult = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(defaultResult, "the build with .git as a file (a worktree/submodule checkout - e.g. an AI agent - must never fail)"); + AssertWarned(defaultResult, "GITPROPS002"); + AssertNoGitPropertiesGenerated(testApp); + + ProcessResult enableWarningsFalseResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertBuildSucceeded(enableWarningsFalseResult, "build"); + AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS002", "resolves to a git worktree or submodule"); + + ProcessResult featureOffResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GenerateGitProperties=false"); + AssertBuildSucceeded(featureOffResult, "build with GenerateGitProperties=false"); + featureOffResult.Output.Should().NotContain("GITPROPS002"); + } + + [Fact] + public void ShallowClone_LeavesCommitCountsEmpty() + { + string source = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "source"), 3); + ProcessRunner.RunGit(source, "tag", "v1.0.0"); + + string shallow = Path.Combine(_workspace.RootDirectory, "shallow"); + // --no-local is required here: for a plain local filesystem path, git's local-clone + // optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, + // producing a full clone that would make this test worthless. + ProcessResult cloneResult = ProcessRunner.RunGit(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); + cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); + ProcessRunner.GetGitOutput(shallow, "rev-parse", "--is-shallow-repository").Should().Be("true"); + + string testApp = _workspace.CopyCurrentProjectFiles(shallow); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "the build against a shallow clone"); + result.Output.Should().NotContain("GITPROPS001"); + result.Output.Should().NotContain("GITPROPS002"); + AssertWarned(result, "GITPROPS006"); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + properties["git.total.commit.count"].Should().BeEmpty(); + properties["git.closest.tag.commit.count"].Should().BeEmpty(); + } + + /// + /// GITPROPS006 (unlike GITPROPS001-005) never blocks generation - the shallow clone is still fully usable, just with two fields left empty (see + /// ). Confirms $(GitPropertiesEnableWarnings) downgrades it to an informational message the same way + /// it does for the others. + /// + [Fact] + public void ShallowClone_InfoWhenEnableWarningsFalse() + { + string source = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "source"), 1); + + string shallow = Path.Combine(_workspace.RootDirectory, "shallow"); + ProcessResult cloneResult = ProcessRunner.RunGit(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); + cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); + + string testApp = _workspace.CopyCurrentProjectFiles(shallow); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertBuildSucceeded(result, "build"); + AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); + } + + [Fact] + public void NonAscii_CommitDataRendersCorrectly() + { + string repository = Path.Combine(_workspace.RootDirectory, "repo"); + Directory.CreateDirectory(repository); + ProcessRunner.RunGit(repository, "init", "--quiet", "--initial-branch=main", "."); + // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 + // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). + const string nonAsciiUserName = "\u00DCn\u00EFc\u00F6d\u00E9 T\u00EBst"; + const string nonAsciiCommitMessage = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; + + ProcessRunner.RunGit(repository, "config", "user.name", nonAsciiUserName); + ProcessRunner.RunGit(repository, "config", "user.email", "test@example.com"); + File.WriteAllText(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n"); + File.WriteAllText(Path.Combine(repository, "file.txt"), "content"); + ProcessRunner.RunGit(repository, "add", "-A"); + ProcessRunner.RunGit(repository, "commit", "--quiet", "-m", nonAsciiCommitMessage); + + string testApp = _workspace.CopyCurrentProjectFiles(repository); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "build"); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + properties["git.commit.user.name"].Should().Be(nonAsciiUserName); + properties["git.commit.message.short"].Should().Be(nonAsciiCommitMessage); + } + + [Fact] + public void MultiProject_SharesCache() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 2); + + // Two independent projects at the repo root, each with their own ProjectReference/Import + // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepo + // already placed TestApp there; reuse that exact relative layout for ProjA/ProjB by placing them + // at the repo root too, siblings of TestApp and "src"). + GitPropertiesTestWorkspace.WriteAppProject(repository, "ProjA"); + GitPropertiesTestWorkspace.WriteAppProject(repository, "ProjB"); + + string projA = Path.Combine(repository, "ProjA"); + string projB = Path.Combine(repository, "ProjB"); + + ProcessResult resultA = ProcessRunner.RunDotnet(projA, "build", "-v:detailed"); + AssertBuildSucceeded(resultA, "ProjA build"); + + resultA.Output.Should().Contain("git.properties: generating shared cache", + "ProjA (first to build) should be the one that actually generates the shared cache."); + + string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); + File.Exists(cacheFile).Should().BeTrue("ProjA's build should have generated the shared cache."); + DateTime cacheWriteTimeAfterA = File.GetLastWriteTimeUtc(cacheFile); + + // Ensure a rewritten file would get a detectably different modified-time. + Thread.Sleep(1100); + + ProcessResult resultB = ProcessRunner.RunDotnet(projB, "build", "-v:detailed"); + AssertBuildSucceeded(resultB, "ProjB build"); + resultB.Output.Should().NotContain("git.properties: generating shared cache", "ProjB should reuse ProjA's cache instead of regenerating it."); + + File.GetLastWriteTimeUtc(cacheFile).Should().Be(cacheWriteTimeAfterA, "ProjB must not have rewritten the shared cache file."); + + Dictionary propertiesA = PropertiesFile.Read(DebugGitPropertiesFile(projA)); + Dictionary propertiesB = PropertiesFile.Read(DebugGitPropertiesFile(projB)); + propertiesB["git.commit.id"].Should().Be(propertiesA["git.commit.id"]); + } + + /// + /// A single multi-targeted project (current TFM plus the one immediately before it - see ) is a + /// different sharing scenario than : MSBuild builds a multi-targeted project's inner TFMs concurrently by default + /// (unlike the two sequential "dotnet build" invocations that test uses), which is exactly the race + /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's cross-process lock exists to handle. Also guards against the regression that fix's first + /// (wrong) attempt introduced: tagging the current commit invalidates the cache without changing the commit ID, so a naive "does the cache already + /// reflect this commit" freshness check would wrongly skip regenerating it. + /// + [Fact] + public void MultiTargetedProject_SharesCacheAcrossTargetFrameworks() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); + string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); + + ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result1, "multi-targeted build"); + + string expectedCommitId = ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD"); + Dictionary[] propertiesBefore = ReadPropertiesForEachFramework(testApp, frameworks); + + foreach (Dictionary properties in propertiesBefore) + { + properties["git.commit.id"].Should().Be(expectedCommitId); + properties["git.tags"].Should().BeEmpty(); + } + + ProcessResult tagResult = ProcessRunner.RunGit(repository, "tag", "v1.0.0"); + tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); + + ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result2, "second multi-targeted build"); + + Dictionary[] propertiesAfter = ReadPropertiesForEachFramework(testApp, frameworks); + + foreach (Dictionary properties in propertiesAfter) + { + properties["git.tags"].Should().Be("v1.0.0", "both target frameworks must observe the new tag, even though the commit it points at didn't change."); + } + } + + [Fact] + public void WriteToProjectDirectory_DefaultsToOff() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "build"); + + File.Exists(FallbackFile(testApp)).Should().BeFalse("the fallback file must not be written into the project directory unless explicitly opted into."); + } + + [Fact] + public void WriteToProjectDirectory_CreatesFallbackFile_OnBuild() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result, "build with GitPropertiesWriteToProjectDirectory=true"); + + result.Output.Should() + .Contain($"git.properties: writing fallback copy to '{FallbackFile(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + + File.Exists(FallbackFile(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + + Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); + Dictionary outputProperties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + fallbackProperties.Should().BeEquivalentTo(outputProperties, "the fallback file must carry the exact same content as the live build output."); + + ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Should() + .BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + + // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. + ProcessResult secondResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(secondResult, "second build"); + + PropertiesFile.Read(DebugGitPropertiesFile(testApp))["git.dirty"].Should().Be("false", + "the gitignored fallback file from the first build must not make a later build see the tree as dirty."); + } + + /// + /// The negative counterpart to - proves the README's ".gitignore this file" warning + /// is describing a real consequence, not a hypothetical one: deliberately uses a repository WITHOUT the fallback file gitignored, so the file the first + /// build writes is left behind as a genuine untracked change - permanently flipping git.dirty to "true" on every later build, even though nothing about + /// the actually-tracked source changed in between. + /// + [Fact] + public void FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult firstResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(firstResult, "first build, which writes the (not yet gitignored) fallback file"); + + ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Should() + .NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); + + ProcessResult secondResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(secondResult, "second build"); + + PropertiesFile.Read(DebugGitPropertiesFile(testApp))["git.dirty"].Should().Be("true", + "the ungitignored fallback file left over from the first build makes every later build see the tree as dirty."); + } + + /// + /// "dotnet publish" runs its own compile/composition steps internally regardless of whether "dotnet build" ran first - this guards against the fallback + /// file only being written along the "build" target chain and silently never firing when publish is the very first command run against a fresh checkout + /// (a common real-world pattern: `dotnet publish` directly, without a separate build step). Runs with $(GitPropertiesEnableWarnings) at its default + /// (enabled) setting to confirm nothing about the fallback-writing path implicitly depends on warnings being suppressed - since a real .git repository + /// is available here, nothing should be skipped (and no GITPROPS0xx code should appear) regardless of that setting. + /// + [Fact] + public void WriteToProjectDirectory_CreatesFallbackFile_OnPublish() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = + ProcessRunner.RunDotnet(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); + + AssertBuildSucceeded(result, "publish with GitPropertiesWriteToProjectDirectory=true, without an upfront build"); + + result.Output.Should().NotContain("GITPROPS0", + "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); + + File.Exists(FallbackFile(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); + + Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); + Dictionary publishedProperties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(testApp)); + fallbackProperties.Should().BeEquivalentTo(publishedProperties, "the fallback file must carry the exact same content as the published output."); + + ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Should() + .BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + } + + /// + /// Guards against a stale fallback file (left over from some earlier build) ever shadowing live generation - the fallback file must only ever be used as + /// a last resort, never preferred over a real, currently-usable .git repository. + /// + [Fact] + public void FallbackFile_Ignored_WhenLiveGitAvailable() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + File.WriteAllLines(FallbackFile(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-v:detailed"); + AssertBuildSucceeded(result, "build with a stale fallback file present alongside a real .git repository"); + result.Output.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + } + + /// + /// End-to-end simulation of the scenario that motivated $(GitPropertiesWriteToProjectDirectory) in the first place: `cf push` using the + /// dotnet_core_buildpack directly from source, which strips ".git" from the pushed tree unconditionally (see SimulateSourcePush) - meaning live + /// generation can never run for that push, ever. A pre-generated fallback file (produced by an earlier LOCAL build, where .git was available) must ride + /// along in the pushed source tree and get picked up, ending up in the published output exactly as if it had been generated live. + /// + [Fact] + public void FallbackFile_UsedWhenNoGitAvailable() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 2, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult fallbackResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(fallbackResult, "the local build that produces the fallback file"); + Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); + fallbackProperties["git.dirty"].Should().Be("false", "the gitignored fallback file must not make its own producing build see the tree as dirty."); + + string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(_workspace.RootDirectory, "pushed")); + string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); + Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); + File.Exists(FallbackFile(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + + ProcessResult publishResult = ProcessRunner.RunDotnet(pushedApp, "publish", "-v:detailed"); + AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); + publishResult.Output.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); + + publishResult.Output.Should() + .Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + + Dictionary publishedProperties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(pushedApp)); + publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); + } + + /// + /// The stable, documented entry point for step 1 of the "Recommended cf push workflow" (see PackageReadme.md) - confirms it actually produces a usable + /// fallback file, and that doing so never compiles anything (the whole reason to prefer it over a full "dotnet build" before a source push). + /// + [Fact] + public void WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(result, "build -t:WriteGitPropertiesFallbackFile"); + + File.Exists(FallbackFile(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + Dictionary properties = PropertiesFile.Read(FallbackFile(testApp)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + + // "bin\Debug\\publish" gets created as empty, routine SDK scaffolding even here (PrepareForPublish's own setup) - checking for the absence + // of the compiled assembly itself, not just the bin directory, is what actually proves no compilation happened. + File.Exists(Path.Combine(testApp, "bin", "Debug", TestPaths.TestAppTargetFramework, $"{GitPropertiesTestWorkspace.TestAppProjectName}.dll")).Should() + .BeFalse("this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); + } + + /// + /// End-to-end simulation of the actual documented workflow - the lightweight-target equivalent of + /// (which uses a full build instead): produce the fallback file via + /// + /// WriteGitPropertiesFallbackFile + /// + /// , simulate a source-based `cf push`, and confirm the server-side publish still picks it up correctly. + /// + [Fact] + public void WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 2, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult writeResult = ProcessRunner.RunDotnet(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); + Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); + + string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(_workspace.RootDirectory, "pushed")); + string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); + File.Exists(FallbackFile(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + + ProcessResult publishResult = ProcessRunner.RunDotnet(pushedApp, "publish", "-v:detailed"); + AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); + + publishResult.Output.Should() + .Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + + Dictionary publishedProperties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(pushedApp)); + publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); + } + + /// + /// "--no-restore" must work the same way for this target as for any other build invocation - it only requires that restore already happened at least + /// once, same as a normal build. + /// + [Fact] + public void WriteGitPropertiesFallbackFile_WorksWithNoRestore() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult restoreResult = ProcessRunner.RunDotnet(testApp, "restore"); + AssertBuildSucceeded(restoreResult, "restore"); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(result, "build --no-restore -t:WriteGitPropertiesFallbackFile"); + + File.Exists(FallbackFile(testApp)).Should().BeTrue(); + } + + /// + /// Documents/guards the one real caveat called out in PackageReadme.md: this target never produces real build output, so a local "dotnet publish + /// --no-build" afterward must fail - there is nothing compiled to publish. If this target's own implementation ever accidentally started producing + /// compiled output (defeating its "lightweight" purpose), this test would start failing for the opposite reason (publish --no-build would start + /// succeeding) - a signal to revisit the target, not just delete this test. + /// + [Fact] + public void WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult writeResult = ProcessRunner.RunDotnet(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); + + ProcessResult publishResult = ProcessRunner.RunDotnet(testApp, "publish", "--no-build"); + + publishResult.ExitCode.Should().NotBe(0, + "publishing --no-build after only writing the fallback file (no real build ever ran) must fail - there is no compiled output to publish."); + } + + /// + /// Every other test here consumes Steeltoe.Management.GitProperties.Build straight from source (ProjectReference + Import) - this is the only one that + /// goes through a real, packed .nupkg via <PackageReference>, the way an actual external user of the package would. That exercises the NuGet + /// "build\{PackageId}.targets" auto-import convention end-to-end (no explicit <Import> anywhere in the consumer project) and the in-process + /// (non-dev-loop) task-loading branch (SourceCheckout.txt is never packed, so it's absent in this layout - see $(GitPropertiesTaskHost) in + /// Steeltoe.Management.GitProperties.Build.targets). Isolated per andrewlock.net's "Creating a source generator, part 3" approach: a local folder feed + /// (just our own freshly-packed .nupkg, via a nuget.config with <clear/>) and a per-test RestorePackagesPath, so this never touches - or gets a + /// stale result from - the machine-wide global-packages cache at %userprofile%\.nuget\packages. + /// + [Fact] + public void NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + + string feedDirectory = _workspace.PackGitPropertiesBuildToFeed(); + + string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{TestPaths.PackageId}.*.nupkg"); + nuPkgFiles.Should().ContainSingle("packing should produce exactly one .nupkg."); + + Match versionMatch = NuPkgVersionRegex.Match(Path.GetFileName(nuPkgFiles[0])); + versionMatch.Success.Should().BeTrue("the .nupkg file name should embed the package version."); + string packageVersion = versionMatch.Groups[1].Value; + + string consumerDirectory = Path.Combine(repository, "Consumer"); + GitPropertiesTestWorkspace.CreatePackageConsumerProject(consumerDirectory, packageVersion); + GitPropertiesTestWorkspace.WriteIsolatedNuGetConfig(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); + + string isolatedPackagesPath = Path.Combine(_workspace.RootDirectory, "isolated-packages"); + ProcessResult result = ProcessRunner.RunDotnet(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); + AssertBuildSucceeded(result, "the build of a project consuming Steeltoe.Management.GitProperties.Build via PackageReference"); + + result.Output.Should().Contain("0 Warning(s)", + "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); + + // NuGet always lowercases the package ID for the on-disk global-packages-folder layout - this isn't + // an arbitrary case normalization, so ToUpperInvariant() (as generally preferred) would look here for + // a folder that NuGet never creates. +#pragma warning disable S4040 + string lowerCasePackageId = TestPaths.PackageId.ToLowerInvariant(); +#pragma warning restore S4040 + + Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should() + .BeTrue("the package should restore into the isolated path, never the machine-wide global-packages cache."); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(consumerDirectory)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + } + + /// + /// The default case for the overwhelming majority of projects in a large solution (a class library, a test project, anything without a consuming package + /// anywhere in its resolved dependency graph): generation is skipped entirely, without needing an explicit opt-out, and without breaking the build. A + /// real git repository is deliberately present here (unlike NoGit_WarnsByDefault) to prove the smart default - not "no .git found" - is what causes the + /// skip. + /// + [Fact] + public void SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-v:detailed"); + AssertBuildSucceeded(result, "build with no consuming-package reference and $(GenerateGitProperties) left at its smart default"); + // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). + result.Output.Should().Contain("git.properties generation skipped: no reference to"); + AssertNoGitPropertiesGenerated(testApp); + } + + /// + /// The positive counterpart to : a project referencing the real default + /// consuming package ID (Steeltoe.Management.Endpoint) gets git.properties generated with no explicit $(GenerateGitProperties) needed. Uses a minimal + /// stand-in project with that exact name/PackageId (see WriteDummyDependencyProject's remarks) rather than the real, large Endpoint project, so this + /// test stays fast and fully offline. + /// + [Fact] + public void SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; + GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, consumingPackageStandInName); + + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "build with a Steeltoe.Management.Endpoint reference and $(GenerateGitProperties) left at its smart default"); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + } + + /// + /// Proves $(GitPropertiesConsumingPackageIds) is genuinely overridable - for consumers of this package who don't use Steeltoe.Management.Endpoint at all + /// (e.g. a hand-rolled /info endpoint reading git.properties directly), so the smart default isn't hardcoded away from them. + /// + [Fact] + public void SmartDefault_Override_DetectsCustomPackageIds() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + const string customPackageId = "Contoso.Actuators"; + GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, customPackageId); + + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); + AssertBuildSucceeded(result, "build with a custom $(GitPropertiesConsumingPackageIds) matching a referenced project"); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + } + + /// + /// Guards against a regression to a naive substring match (e.g. "IndexOf(id + "/")" without also requiring the match to be a whole library key) - a + /// project referencing only "Some2" (never "Some" itself) must NOT be detected when $(GitPropertiesConsumingPackageIds) is configured as "Some", even + /// though "Some2" starts with "Some". Proves DetectConsumingPackageReferenceTask compares whole package IDs, not prefixes. + /// + [Fact] + public void SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + const string longerPackageId = "Some2"; + GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, longerPackageId); + + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); + AssertBuildSucceeded(result, "build with a referenced package ('Some2') that is a superstring, not a match, of the configured ID ('Some')"); + AssertNoGitPropertiesGenerated(testApp); + } + + /// + /// Guards against a regression where MSBuild's required-parameter check for a Task string parameter treats an empty string the same as "not supplied": + /// setting $(GitPropertiesConsumingPackageIds) to blank via a global property (e.g. "-p:GitPropertiesConsumingPackageIds=") reaches + /// DetectConsumingPackageReferenceTask.PackageIds unchanged (global properties can't be reassigned by the project's own conditional default at + /// ResolveGitPropertiesPaths above), so PackageIds must NOT be [Required] - it must instead behave exactly like "no configured ID happens to match", + /// i.e. skip generation gracefully rather than fail the build with MSB4044. + /// + [Fact] + public void SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; + GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, consumingPackageStandInName); + + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); + AssertBuildSucceeded(result, "build with $(GitPropertiesConsumingPackageIds) explicitly cleared via a global property"); + AssertNoGitPropertiesGenerated(testApp); + } + + /// + /// A consumer's explicit choice must never be second-guessed by the smart default, in either direction - the negative direction (no reference, but + /// explicitly forced on) is already exercised by every other test in this file, which all set $(GenerateGitProperties)=true explicitly via + /// WriteAppProject's default. This covers the other direction: a consuming-package reference IS present (the smart default would say "generate"), but + /// the consumer explicitly opted out anyway. + /// + [Fact] + public void SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; + GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, consumingPackageStandInName); + + string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GenerateGitProperties=false"); + AssertBuildSucceeded(result, "build with GenerateGitProperties explicitly set to false despite a consuming-package reference being present"); + AssertNoGitPropertiesGenerated(testApp); + } + + private static Dictionary[] ReadPropertiesForEachFramework(string projectDirectory, string[] frameworks) + { + return Array.ConvertAll(frameworks, framework => PropertiesFile.Read(Path.Combine(projectDirectory, "bin", "Debug", framework, "git.properties"))); + } + + private static string FallbackFile(string projectDirectory) + { + return Path.Combine(projectDirectory, "git.properties"); + } + + public void Dispose() + { + _workspace.Dispose(); + } + + private static void AssertBuildSucceeded(ProcessResult result, string action) + { + result.ExitCode.Should().Be(0, "{0} should succeed. Output:\n{1}", action, result.Output); + } + + private static void AssertWarned(ProcessResult result, string code) + { + result.Output.Should().Contain($"warning {code}"); + } + + /// + /// GitPropertiesEnableWarnings=false downgrades a diagnostic from a Warning to a plain informational message - with no code at all (see + /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why), and at Importance="Normal" rather than the default's "high", so it's visible at + /// "-v:normal" but not in default build output. + /// + private static void AssertReportedAsInfoOnly(ProcessResult result, string code, string messageSnippet) + { + result.Output.Should().NotContain(code, "a downgraded message must never carry a code - only warnings do."); + result.Output.Should().Contain(messageSnippet); + } + + private static void AssertNoGitPropertiesGenerated(string projectDirectory) + { + File.Exists(DebugGitPropertiesFile(projectDirectory)).Should().BeFalse("no git.properties should be generated."); + } + + private static string DebugGitPropertiesFile(string projectDirectory) + { + return Path.Combine(projectDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + } + + private static string ReleasePublishGitPropertiesFile(string projectDirectory) + { + return Path.Combine(projectDirectory, "bin", "Release", TestPaths.TestAppTargetFramework, "publish", "git.properties"); + } +} 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..086a9c0917 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -0,0 +1,393 @@ +// 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; + +/// +/// An isolated temporary directory tree for a single test, cleaned up on Dispose. Deliberately avoids "gitprop" in its own name (in any casing) so a +/// test's workspace path can never accidentally satisfy an Assert.Contains/DoesNotContain check against a GITPROPS0xx diagnostic code in build output, +/// which routinely echoes back the working directory path. +/// +internal sealed class GitPropertiesTestWorkspace : IDisposable +{ + /// + /// The project name every dev-loop consumer test writes its own copy of Steeltoe.Management.GitProperties.Build against (see + /// ) - shared so callers never retype it. + /// + public const string TestAppProjectName = "TestApp"; + + private static readonly HashSet SimulatedPushExcludedDirectoryNames = new(StringComparer.OrdinalIgnoreCase) + { + ".git", + "bin", + "obj" + }; + + public string RootDirectory { get; } + + public GitPropertiesTestWorkspace() + { + RootDirectory = Path.Combine(Path.GetTempPath(), $"build-tasks-test_{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8]}"); + Directory.CreateDirectory(RootDirectory); + } + + public void Dispose() + { + // Set GITPROPERTIES_KEEP_TEST_WORKSPACES=1 to inspect a workspace after the run instead of + // having it deleted here. + if (Environment.GetEnvironmentVariable("GITPROPERTIES_KEEP_TEST_WORKSPACES") == "1") + { + return; + } + + try + { + // git marks files under .git\objects (and packed clones) read-only on Windows, which + // makes a plain recursive delete throw UnauthorizedAccessException - PowerShell's + // Remove-Item -Force clears this automatically, but Directory.Delete does not. + 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); + } + } + + /// + /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source (csproj + all .cs files + the targets file + SourceCheckout.txt) into + /// "src\Management\src\GitProperties.Build" under the given repository root - the exact relative depth its own csproj's "shared.props" import expects. + /// Copying the marker file matters: without it, $(GitPropertiesTaskHost) in Steeltoe.Management.GitProperties.Build.targets would (correctly, but + /// unhelpfully for these tests) detect "packaged" consumption instead of the dev loop this is meant to simulate, silently skipping the TaskHostFactory + /// path every other test here relies on. + /// + private static void CopyGitPropertiesBuildSource(string repoRootDestination) + { + string destination = Path.Combine(repoRootDestination, TestPaths.GitPropertiesBuildRelativePath); + Directory.CreateDirectory(Path.Combine(destination, "build")); + File.Copy(TestPaths.GitPropertiesBuildProjectFile, Path.Combine(destination, Path.GetFileName(TestPaths.GitPropertiesBuildProjectFile)), true); + File.Copy(TestPaths.TargetsFile, Path.Combine(destination, "build", Path.GetFileName(TestPaths.TargetsFile)), true); + File.Copy(TestPaths.SourceCheckoutMarkerFile, Path.Combine(destination, Path.GetFileName(TestPaths.SourceCheckoutMarkerFile)), true); + + foreach (string sourceFile in Directory.GetFiles(TestPaths.GitPropertiesBuildDirectory, "*.cs")) + { + File.Copy(sourceFile, Path.Combine(destination, Path.GetFileName(sourceFile)), true); + } + } + + /// + /// Copies the handful of root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own shared.props import + /// chain needs (versioning, packaging defaults, analyzers), so a synthetic test repo - which has none of Steeltoe's other real projects - still + /// evaluates the same properties a real build inside the repository would. + /// + private static void CopySharedBuildInfrastructure(string repoRootDestination) + { + Directory.CreateDirectory(repoRootDestination); + + foreach (string fileName in TestPaths.SharedBuildInfrastructureFiles) + { + File.Copy(Path.Combine(TestPaths.RepositoryRoot, fileName), Path.Combine(repoRootDestination, fileName), true); + } + } + + /// + /// Writes a minimal exe project named under the given repository root, referencing + /// Steeltoe.Management.GitProperties.Build the way a same-solution project (dev loop) would: a ReferenceOutputAssembly="false" ProjectReference (just to + /// order the build) plus an explicit Import of the .targets file - the NuGet "build\{PackageId}.targets" auto-import convention only kicks in for a real + /// PackageReference consumer (see NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties for that). Single-targeted at + /// unless is given, in which case the project multi-targets that + /// semicolon-separated list instead (see ). + /// + /// + /// The directory to write the project under - typically a repository root returned by . + /// + /// + /// The project's name - also used as its directory and file name. + /// + /// + /// A semicolon-separated list of target frameworks to multi-target, or null for a single-targeted project at + /// . + /// + /// + /// Emits an explicit $(GenerateGitProperties) override when non-null - true for almost every test here, since they exist to test generation itself, not + /// the smart default that decides whether it runs at all. Pass null (letting the smart default apply) only for tests that specifically cover + /// DetectConsumingPackageReferenceTask/$(GitPropertiesConsumingPackageIds) - see . + /// + /// + /// Extra raw XML inserted into the same <ItemGroup> as the ProjectReference above - currently only used to add a second, normal (not + /// ReferenceOutputAssembly="false") ProjectReference to a stand-in, so it actually participates in restore + /// and shows up in this project's own project.assets.json (see DetectConsumingPackageReferenceTask's remarks for why that distinction matters). + /// + public static string WriteAppProject(string repoRootDestination, string projectName, string? targetFrameworks = null, bool? generateGitProperties = true, + string? extraItemGroupContent = null) + { + string appDirectory = Path.Combine(repoRootDestination, projectName); + Directory.CreateDirectory(appDirectory); + + string targetFrameworkElement = targetFrameworks == null + ? $"{TestPaths.TestAppTargetFramework}" + : $"{targetFrameworks}"; + + string generateGitPropertiesElement = string.Empty; + + if (generateGitProperties != null) + { + string generateGitPropertiesValue = generateGitProperties.Value ? "true" : "false"; + generateGitPropertiesElement = $"{generateGitPropertiesValue}"; + } + + string projectContent = $""" + + + Exe + {targetFrameworkElement} + {generateGitPropertiesElement} + enable + enable + + + + + false + + {extraItemGroupContent} + + + + + """; + + File.WriteAllText(Path.Combine(appDirectory, $"{projectName}.csproj"), projectContent); + + File.WriteAllText(Path.Combine(appDirectory, "Program.cs"), """ + Console.WriteLine("Hello, World!"); + """); + + return appDirectory; + } + + /// + /// Writes a minimal, do-nothing class library project - used only by the smart-default detection tests as a stand-in for a real dependency (e.g. + /// Steeltoe.Management.Endpoint itself, or some other actuator-registering package) that a test app references NORMALLY (see + /// 's caller), so it actually participates in restore and shows up in the referencing project's own + /// project.assets.json - unlike WriteAppProject's own ReferenceOutputAssembly="false" reference to Steeltoe.Management.GitProperties.Build, which was + /// verified (empirically, against a real "dotnet restore") to be excluded from the resolved dependency graph entirely. + /// + public static string WriteDummyDependencyProject(string repoRootDestination, string projectName) + { + string projectDirectory = Path.Combine(repoRootDestination, projectName); + Directory.CreateDirectory(projectDirectory); + + File.WriteAllText(Path.Combine(projectDirectory, $"{projectName}.csproj"), $""" + + + {TestPaths.TestAppTargetFramework} + + + """); + + return projectDirectory; + } + + /// + /// A brand-new, synthetic git repo with a controlled, minimal history (`git init` plus a handful of manufactured commits) - deliberately never a clone + /// of this (large, real) repository, so the suite stays fast. Returns the repository root (not the TestApp directory - callers combine "TestApp" + /// themselves, mirroring how multi-project tests place additional sibling projects at the same root). + /// + /// + /// The directory to initialize the repository in. + /// + /// + /// The number of manufactured commits to create before the project files are added. + /// + /// + /// Whether to also list "git.properties" in the repository's .gitignore, modeling the setup a real consumer of $(GitPropertiesWriteToProjectDirectory) + /// must follow - see SimulateSourcePush for the complementary "not .cfignore'd" half of that same guidance. Defaults to false so most tests (which never + /// write a fallback file into the project directory at all) aren't given a gitignore entry they don't exercise - only pass true for tests that + /// specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that accidentally wrote one in a test that doesn't + /// expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being silently absorbed by a blanket ignore rule. + /// + public string CreateSyntheticRepo(string destination, int commitCount, bool gitignoreFallbackFile = false) + { + Directory.CreateDirectory(destination); + ProcessRunner.RunGit(destination, "init", "--quiet", "--initial-branch=main", "."); + ProcessRunner.RunGit(destination, "config", "user.name", "Test User"); + ProcessRunner.RunGit(destination, "config", "user.email", "test@example.com"); + + // Without this, dotnet build's own obj/bin output is untracked and git status correctly + // (but unhelpfully, for these tests) reports the tree as dirty - real projects always + // gitignore build output, same as this repo does. + string gitignoreContent = gitignoreFallbackFile + ? """ + bin/ + obj/ + git.properties + """ + : """ + bin/ + obj/ + """; + + File.WriteAllText(Path.Combine(destination, ".gitignore"), gitignoreContent); + + for (int commitNumber = 1; commitNumber <= commitCount; commitNumber++) + { + File.WriteAllText(Path.Combine(destination, $"file{commitNumber}.txt"), $"content {commitNumber}"); + ProcessRunner.RunGit(destination, "add", "-A"); + ProcessRunner.RunGit(destination, "commit", "--quiet", "-m", $"Commit {commitNumber}"); + } + + CopyCurrentProjectFiles(destination); + + // Commit the project files too, so the synthetic repo starts clean (git.dirty=false) + // unless a test deliberately makes a further change - otherwise every synthetic repo would + // show git.dirty=true purely because of these untracked-but-just-added files. + ProcessRunner.RunGit(destination, "add", "-A"); + ProcessRunner.RunGit(destination, "commit", "--quiet", "-m", "Add project files"); + return destination; + } + + /// + /// Copies a directory tree to a brand-new location with no ".git" anywhere in its ancestry, simulating what actually reaches a running app when deployed + /// via `cf push` using the dotnet_core_buildpack from source: ".git" is excluded from the pushed tree unconditionally by `cf push` itself (a CLI-level + /// default, independent of ".cfignore" and not something the buildpack has any special handling for - verified against both tools' source), which is + /// exactly why live git.properties generation can never run server-side for that scenario. "bin"/"obj" are also excluded, mirroring the ".cfignore" + /// hygiene real projects need anyway (both to avoid pushing stale local build output, and because reusing another location's "obj" as-is would confuse + /// MSBuild's own incremental state, which embeds absolute paths). Anything else - including an already-generated fallback "git.properties" sitting next + /// to the ".csproj" - is copied as-is, exactly as it would ride along in the real push payload. + /// + public static string SimulateSourcePush(string sourceDirectory, string destinationDirectory) + { + CopyDirectoryExcluding(new DirectoryInfo(sourceDirectory), destinationDirectory, SimulatedPushExcludedDirectoryNames); + return destinationDirectory; + } + + 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); + } + } + + /// + /// Copies the shared Steeltoe build infrastructure plus the CURRENT Steeltoe.Management.GitProperties.Build source into the given directory (which + /// becomes the "repository root" for relative-path purposes, whether or not it's actually a git repository), then writes a TestApp project referencing + /// it. Returns the TestApp directory. + /// + public string CopyCurrentProjectFiles(string destination) + { + CopySharedBuildInfrastructure(destination); + CopyGitPropertiesBuildSource(destination); + return WriteAppProject(destination, TestAppProjectName); + } + + /// + /// Packs a FRESH COPY of the current Steeltoe.Management.GitProperties.Build source into a local folder feed, so the PackageReference-based consumption + /// test exercises the exact same source as every other test here - not a stale .nupkg left behind by an earlier `dotnet build` of the real repo. A plain + /// filesystem directory is a valid NuGet feed on its own; no server involved. Returns the feed directory: a plain `dotnet build` (not `dotnet pack`) + /// because GeneratePackageOnBuild already produces the .nupkg as a side effect of a Release build - `dotnet pack` alone does NOT build first here + /// (IncludeBuildOutput=false makes NuGet's Pack target skip its usual dependency on Build), so it fails with NU5019 ("file not found") against the DLL + /// our own <None Include="$(TargetPath)"> pack item expects to already exist. + /// + public string PackGitPropertiesBuildToFeed() + { + string packSourceDirectory = Path.Combine(RootDirectory, "pack-source"); + CopySharedBuildInfrastructure(packSourceDirectory); + CopyGitPropertiesBuildSource(packSourceDirectory); + + string projectDirectory = Path.Combine(packSourceDirectory, TestPaths.GitPropertiesBuildRelativePath); + ProcessResult result = ProcessRunner.RunDotnet(projectDirectory, "build", Path.GetFileName(TestPaths.GitPropertiesBuildProjectFile), "-c", "Release"); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Building/packing {TestPaths.PackageId} failed.\n{result.Output}"); + } + + return Path.Combine(projectDirectory, "bin", "tasks", TestPaths.GitPropertiesBuildTargetFramework); + } + + /// + /// A minimal nuget.config that clears every inherited package source (machine-wide, user-wide, ambient) down to just the given local feed. Without < + /// clear/>, restore would still see nuget.org and friends - harmless here since nothing else is needed, but clearing makes the test fully + /// offline-capable and guarantees it's really our local build being consumed, not a same-named/versioned package resolved from somewhere else. + /// + public static void WriteIsolatedNuGetConfig(string filePath, string feedDirectory) + { + string content = $""" + + + + + + + + """; + + File.WriteAllText(filePath, content); + } + + /// + /// A bare console app that consumes Steeltoe.Management.GitProperties.Build the way a real, external user would - via <PackageReference> against a + /// built .nupkg - as opposed to every other test here, which uses a ProjectReference/Import straight against source. No explicit <Import> of the + /// .targets file: that's the whole point of the "build\{PackageId}.targets" NuGet auto-import convention this package relies on, and this is the only + /// test that actually exercises it end-to-end. + /// + public static void CreatePackageConsumerProject(string projectDirectory, string packageVersion) + { + Directory.CreateDirectory(projectDirectory); + + string projectContent = $""" + + + Exe + {TestPaths.TestAppTargetFramework} + enable + + true + + + + + + + """; + + File.WriteAllText(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent); + + File.WriteAllText(Path.Combine(projectDirectory, "Program.cs"), """ + Console.WriteLine("Hello, World!"); + """); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/ProcessResult.cs b/src/Management/test/GitProperties.Build.Test/ProcessResult.cs new file mode 100644 index 0000000000..850917c30b --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/ProcessResult.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 sealed record ProcessResult(int ExitCode, string Output); 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..0cfae78432 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -0,0 +1,108 @@ +// 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; + +/// +/// Runs external processes (git, dotnet) and captures stdout/stderr merged into a single string - tests assert on substrings (diagnostic codes, +/// "Skipping target", etc.) that can land on either stream. +/// +internal static class ProcessRunner +{ + private static readonly string LocatorCommand = OperatingSystem.IsWindows() ? "where" : "which"; + + private static readonly char[]? LineSeparators = + [ + '\r', + '\n' + ]; + + public static readonly string RealGitExecutable = ResolveGitExecutable(); + + public static ProcessResult Run(string fileName, string workingDirectory, params string[] arguments) + { + var outputBuilder = new StringBuilder(); + object 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); + } + + 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(); + process.WaitForExit(); + + string output = outputBuilder.ToString(); + return new ProcessResult(process.ExitCode, output); + + void AppendLine(string? line) + { + if (line == null) + { + return; + } + + // Deliberately a call-scoped lock, not a shared static one: many tests run processes + // concurrently (parallel test execution, each spawning further child processes such as + // MSBuild worker nodes or the git shim), and every process's stdout/stderr callback + // would otherwise contend for one single global lock. Under enough concurrent, + // high-volume output (e.g. "dotnet build -v:detailed"), that starves the thread pool + // and stalls the whole suite indefinitely - this lock only ever guards this one call's + // own StringBuilder. +#pragma warning disable S6507 + lock (outputLock) +#pragma warning restore S6507 + { + outputBuilder.AppendLine(line); + } + } + } + + public static ProcessResult RunGit(string workingDirectory, params string[] arguments) + { + return Run(RealGitExecutable, workingDirectory, arguments); + } + + public static ProcessResult RunDotnet(string workingDirectory, params string[] arguments) + { + return Run("dotnet", workingDirectory, arguments); + } + + public static string GetGitOutput(string workingDirectory, params string[] arguments) + { + ProcessResult result = RunGit(workingDirectory, arguments); + return result.Output.Trim(); + } + + private static string ResolveGitExecutable() + { + ProcessResult result = Run(LocatorCommand, Path.GetTempPath(), "git"); + + string firstLine = result.Output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? + throw new InvalidOperationException($"Could not resolve the location of git via '{LocatorCommand} git'."); + + return firstLine.Trim(); + } +} 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..72d25f44f8 --- /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 Dictionary Read(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"git.properties not found at: {path}"); + } + + var map = new Dictionary(); + + foreach (string line in File.ReadAllLines(path, Encoding.UTF8)) + { + 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/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..152bfcbc3f --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/Steeltoe.Management.GitProperties.Build.Test.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + + + + + + + + + diff --git a/src/Management/test/GitProperties.Build.Test/TestPaths.cs b/src/Management/test/GitProperties.Build.Test/TestPaths.cs new file mode 100644 index 0000000000..6fe0975559 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/TestPaths.cs @@ -0,0 +1,130 @@ +// 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.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Steeltoe.Management.GitProperties.Build.Test; + +/// +/// Resolves paths to the CURRENT source of Steeltoe.Management.GitProperties.Build (not a stale git-tracked copy), so every test exercises whatever is +/// on disk right now. Resolved once per test run via the test source file's own location, so these tests work regardless of where the test assembly's +/// bin/ output lives. +/// +internal static partial class TestPaths +{ + /// + /// The relative path (from a project sitting at the root of a test workspace) back to the copied Steeltoe.Management.GitProperties.Build source - see + /// . Mirrors the real repository's own "src/Management/src/GitProperties.Build" layout, since + /// Steeltoe.Management.GitProperties.Build.csproj's own "shared.props" import depends on being at that exact relative depth from the repository root. + /// + public const string GitPropertiesBuildRelativePath = "src/Management/src/GitProperties.Build"; + + /// + /// The TargetFramework every generated test-app .csproj (TestApp, ProjA/ProjB, the PackageReference consumer) is written with - read from this test + /// assembly's own $(TargetFramework) via the AssemblyMetadata item in Steeltoe.Management.GitProperties.Build.Test.csproj, so bumping that one property + /// is the only change needed when a new TFM becomes current. + /// + public static readonly string TestAppTargetFramework = ResolveTestAppTargetFramework(); + + /// + /// A semicolon-separated "<TargetFrameworks>" value covering the current TFM () plus the one immediately + /// before it, in that order - e.g. "net10.0;net9.0". Computed rather than hardcoded, so this stays current automatically as the repository's own + /// baseline TFM moves forward. Used only by tests that specifically need a multi-targeted consumer project (MSBuild builds a multi-targeted project's + /// inner TFMs concurrently by default), rather than every generated test-app project. + /// + public static readonly string MultiTargetTestFrameworks = BuildMultiTargetTestFrameworks(); + + public static readonly string RepositoryRoot = ResolveRepositoryRoot(); + + public static readonly string GitPropertiesBuildDirectory = Path.Combine(RepositoryRoot, "src", "Management", "src", "GitProperties.Build"); + public static readonly string GitPropertiesBuildProjectFile = Path.Combine(GitPropertiesBuildDirectory, "Steeltoe.Management.GitProperties.Build.csproj"); + public static readonly string TargetsFile = Path.Combine(GitPropertiesBuildDirectory, "build", "Steeltoe.Management.GitProperties.Build.targets"); + public static readonly string SourceCheckoutMarkerFile = Path.Combine(GitPropertiesBuildDirectory, "SourceCheckout.txt"); + + /// + /// The NuGet package/assembly ID, derived from the real .csproj's own file name rather than retyped, so a future rename only needs to happen once. + /// + public static readonly string PackageId = Path.GetFileNameWithoutExtension(GitPropertiesBuildProjectFile); + + /// + /// Steeltoe.Management.GitProperties.Build.csproj's own <TargetFramework> (netstandard2.0, as of this writing) - parsed from the real .csproj + /// rather than hardcoded, so a future retargeting can't silently desync this from the test that packs and consumes the compiled task assembly. + /// + public static readonly string GitPropertiesBuildTargetFramework = ResolveGitPropertiesBuildTargetFramework(); + + /// + /// Root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own "shared.props" import (and that file's own + /// imports) needs to resolve. Copied verbatim into every synthetic test repo, at the repository root, so a project built from this source outside the + /// real Steeltoe checkout still evaluates identically (analyzers, versioning, packaging properties, etc.). + /// + public static readonly string[] SharedBuildInfrastructureFiles = + [ + "shared.props", + "shared-package.props", + "shared-project.props", + "versions.props", + "Directory.Build.targets", + "stylecop.json", + "PackageIcon.png", + "PackageReadme.md", + "Steeltoe.Debug.ruleset", + "Steeltoe.Release.ruleset" + ]; + + private static string ResolveGitPropertiesBuildTargetFramework() + { + string projectContent = File.ReadAllText(GitPropertiesBuildProjectFile); + Match match = TargetFrameworkRegex().Match(projectContent); + + if (!match.Success) + { + throw new InvalidOperationException($"Could not find in {GitPropertiesBuildProjectFile}."); + } + + return match.Groups[1].Value; + } + + [GeneratedRegex("(.+?)")] + private static partial Regex TargetFrameworkRegex(); + + private static string BuildMultiTargetTestFrameworks() + { + Match match = NetTfmRegex().Match(TestAppTargetFramework); + + if (!match.Success) + { + throw new InvalidOperationException($"Could not parse a 'netX.0'-style TFM from '{TestAppTargetFramework}'."); + } + + int majorVersion = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + return $"{TestAppTargetFramework};net{majorVersion - 1}.0"; + } + + [GeneratedRegex(@"^net(\d+)\.0$")] + private static partial Regex NetTfmRegex(); + + private static string ResolveTestAppTargetFramework() + { + AssemblyMetadataAttribute? attribute = Assembly.GetExecutingAssembly().GetCustomAttributes() + .FirstOrDefault(candidate => candidate.Key == "TargetFramework"); + + return attribute?.Value ?? throw new InvalidOperationException("Could not resolve this test assembly's own TargetFramework from its AssemblyMetadata."); + } + + private static string ResolveRepositoryRoot([CallerFilePath] string sourceFilePath = "") + { + string sourceDirectory = Path.GetDirectoryName(sourceFilePath) ?? throw new InvalidOperationException("Could not determine the test source directory."); + ProcessResult result = ProcessRunner.Run(ProcessRunner.RealGitExecutable, sourceDirectory, "rev-parse", "--show-toplevel"); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Could not resolve the repository root from {sourceDirectory}."); + } + + return result.Output.Trim().Replace('/', Path.DirectorySeparatorChar); + } +} 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 1eb9393ada..b8ccd0451b 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.* From 06b73fec16a30af7262e8e280ccc26b318cc9b90 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:52:45 +0200 Subject: [PATCH 02/20] Atomic move --- .../src/GitProperties.Build/AtomicFile.cs | 142 ++++++++++++++++++ .../ComposeGitPropertiesTask.cs | 22 ++- .../GenerateGitPropertiesCacheTask.cs | 62 ++++---- .../GitPropertiesFileWriter.cs | 117 --------------- .../GitPropertiesFormat.cs | 27 ++++ 5 files changed, 216 insertions(+), 154 deletions(-) create mode 100644 src/Management/src/GitProperties.Build/AtomicFile.cs delete mode 100644 src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs create mode 100644 src/Management/src/GitProperties.Build/GitPropertiesFormat.cs diff --git a/src/Management/src/GitProperties.Build/AtomicFile.cs b/src/Management/src/GitProperties.Build/AtomicFile.cs new file mode 100644 index 0000000000..e28db56e00 --- /dev/null +++ b/src/Management/src/GitProperties.Build/AtomicFile.cs @@ -0,0 +1,142 @@ +// 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; + +/// +/// 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 multiple target frameworks of the same project - concurrently by default. When several of those share a single +/// file on disk, two problems follow: a reader can open the file at the exact moment a writer is replacing its content, and two writers can try to +/// update the same file at once. Plain file I/O handles neither case reliably, so every method here is built to tolerate both: writes go through a swap +/// that never leaves the file half-written, reads and writes both 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 instead of every one of them racing to produce the same result. +/// +internal static class AtomicFile +{ + /// + /// How many times a failed read or write is retried, and how long to wait between attempts. + /// + 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 so that anyone reading it - even concurrently - 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 WriteAtomic(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); + Exception? lastError = null; + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + File.WriteAllText(tempPath, $"{string.Join("\n", lines)}\n", encoding); + MoveOrReplace(tempPath, path); + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + lastError = exception; + Thread.Sleep(ReadWriteRetryDelay); + } + } + + throw new IOException($"Failed to write {path}", lastError); + } + + /// + /// Reads all lines from , retrying briefly if another process is momentarily in the way - the read-side counterpart to + /// . + /// + public static string[] ReadAllLinesWithRetry(string path) + { + Exception? lastError = null; + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + return File.ReadAllLines(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + lastError = exception; + Thread.Sleep(ReadWriteRetryDelay); + } + } + + throw new IOException($"Failed to read {path}", lastError); + } + + /// + /// Moves a freshly-written file into place, whether or not something is already there. + /// + private static void MoveOrReplace(string sourcePath, string destinationPath) + { + try + { + File.Move(sourcePath, destinationPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException && File.Exists(destinationPath)) + { + File.Replace(sourcePath, destinationPath, null); + } + } + + /// + /// Attempts to become the sole holder of across every process trying to acquire it, for up to + /// . Returns null if that doesn't happen in time, or if locking isn't possible at all in this environment - holding this lock + /// is only ever an optimization (letting one process do some expensive work while everyone else waits and reuses the result, instead of every process + /// redoing it independently), 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; + + while (true) + { + try + { + return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (CurrentTimeUtc >= deadlineUtc) + { + return null; + } + + Thread.Sleep(AcquireLockRetryDelay); + } + } + } +} diff --git a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs index bc4d96e461..bf00f2f8ea 100644 --- a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -66,21 +66,31 @@ public override bool Execute() } bool isDirty = stdout.Length > 0; - List lines = File.ReadAllLines(CacheFile).ToList(); + List lines; + + try + { + lines = AtomicFile.ReadAllLinesWithRetry(CacheFile).ToList(); + } + catch (IOException exception) + { + Log.LogError($"git.properties: failed to read {CacheFile}: {exception.Message}"); + return false; + } for (int index = 0; index < lines.Count; index++) { - if (isDirty && lines[index].StartsWith($"{GitPropertiesFileWriter.CommitIdDescribeKey}=", StringComparison.Ordinal)) + if (isDirty && lines[index].StartsWith($"{GitPropertiesFormat.CommitIdDescribeKey}=", StringComparison.Ordinal)) { lines[index] += "-dirty"; } } lines.Add($"git.dirty={(isDirty ? "true" : "false")}"); - lines.Add($"git.build.version={GitPropertiesFileWriter.EscapeLineBreaks(Version)}"); + lines.Add($"git.build.version={GitPropertiesFormat.EscapeLineBreaks(Version)}"); // S6354 (use an injectable time provider): not practical here, for the same reason as - // GitPropertiesFileWriter.TryAcquireExclusiveLock - see that method's remarks. Matches the + // AtomicFile.TryAcquireExclusiveLock - see that method's remarks. Matches the // ISO-8601-with-offset style git itself uses for git.commit.time (%cI), rather than // normalizing to UTC - this is "when this build ran, in the build machine's own local time", // not a value that needs to compare directly against the commit's own timestamp. @@ -91,7 +101,7 @@ public override bool Execute() try { - GitPropertiesFileWriter.WriteAtomic(OutputFile, lines); + AtomicFile.WriteAtomic(OutputFile, lines); } catch (IOException exception) { @@ -106,7 +116,7 @@ public override bool Execute() try { - GitPropertiesFileWriter.WriteAtomic(FallbackFile, lines); + AtomicFile.WriteAtomic(FallbackFile, lines); } catch (IOException exception) { diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index acb219e5d1..80785d3a01 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -245,18 +245,18 @@ private GitVersionStatus CheckGitVersion() /// From here on, any failure is unexpected and fatal - git and the repository are already known-good (see ). /// /// - /// Wrapped in a cross-process lock (see ): MSBuild's own Inputs/Outputs staleness check - /// (in Steeltoe.Management.GitProperties.Build.targets) runs independently per project/TFM, with no coordination between them - so when multiple - /// projects or TFMs of the same multi-targeted project build concurrently (MSBuild's default), more than one can see the shared cache as stale and - /// decide to invoke this task at the same time, before either has written a fresh one. Deliberately does NOT try to validate whether the existing cache - /// content is itself still correct/up to date - that's the target-level Inputs/Outputs check's job, and it has already decided we need to run. The only - /// thing checked here is whether the file was rewritten by someone else WHILE this call was waiting for the lock: only then is skipping safe, because a - /// concurrent write during that narrow window must be reacting to the exact same staleness trigger, in the same build, against the same repository - /// state. (An early, wrong attempt at this compared the cache's stored commit ID to the current one - but tagging an existing commit, for example, - /// invalidates the cache without changing the commit ID, so that check silently skipped writes that were actually needed.) This is purely a "thundering - /// herd" optimization, not a correctness fix - already guarantees no reader ever observes a - /// torn/partial file even without it, so failing to acquire the lock (or an environment where locking isn't possible at all) safely falls back to doing - /// the work anyway, same as before this existed. + /// Wrapped in a cross-process lock (see ): MSBuild's own Inputs/Outputs staleness check (in + /// Steeltoe.Management.GitProperties.Build.targets) runs independently per project/TFM, with no coordination between them - so when multiple projects or + /// TFMs of the same multi-targeted project build concurrently (MSBuild's default), more than one can see the shared cache as stale and decide to invoke + /// this task at the same time, before either has written a fresh one. Deliberately does NOT try to validate whether the existing cache content is itself + /// still correct/up to date - that's the target-level Inputs/Outputs check's job, and it has already decided we need to run. The only thing checked here + /// is whether the file was rewritten by someone else WHILE this call was waiting for the lock: only then is skipping safe, because a concurrent write + /// during that narrow window must be reacting to the exact same staleness trigger, in the same build, against the same repository state. (An early, + /// wrong attempt at this compared the cache's stored commit ID to the current one - but tagging an existing commit, for example, invalidates the cache + /// without changing the commit ID, so that check silently skipped writes that were actually needed.) This is purely a "thundering herd" optimization, + /// not a correctness fix - already guarantees no reader ever observes a torn/partial file even without it, so + /// failing to acquire the lock (or an environment where locking isn't possible at all) safely falls back to doing the work anyway, same as before this + /// existed. /// private bool TryGenerateAndWriteCache(string commitId) { @@ -269,7 +269,7 @@ private bool TryGenerateAndWriteCache(string commitId) // later, and "delete, then someone else recreates the same path" is the classic TOCTOU race that breaks a // file-based mutex's mutual exclusion guarantee - simplest to just never delete it and let every build reuse // the same, already-existing lock file. - using FileStream? cacheLock = GitPropertiesFileWriter.TryAcquireExclusiveLock($"{CacheFile}.lock", TimeSpan.FromSeconds(30)); + using FileStream? cacheLock = AtomicFile.TryAcquireExclusiveLock($"{CacheFile}.lock", TimeSpan.FromSeconds(30)); if (cacheLock != null && WasCacheRewrittenWhileWaitingForLock(cacheWriteTimeBeforeLock)) { @@ -326,28 +326,28 @@ private bool TryGenerateAndWriteCache(string commitId) List lines = [ - $"git.branch={GitPropertiesFileWriter.EscapeLineBreaks(branch)}", - $"git.commit.id={GitPropertiesFileWriter.EscapeLineBreaks(commitId)}", - $"git.commit.id.abbrev={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.AbbrevId)}", - $"{GitPropertiesFileWriter.CommitIdDescribeKey}={GitPropertiesFileWriter.EscapeLineBreaks(tagDescription.BaseDescribe)}", - $"git.commit.time={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.CommitTime)}", - $"git.commit.message.short={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.ShortMessage)}", - $"git.commit.message.full={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.FullMessage)}", - $"git.commit.user.name={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.AuthorName)}", - $"git.commit.user.email={GitPropertiesFileWriter.EscapeLineBreaks(logEntry.AuthorEmail)}", - $"git.build.host={GitPropertiesFileWriter.EscapeLineBreaks(buildHost)}", - $"git.build.user.name={GitPropertiesFileWriter.EscapeLineBreaks(config.UserName)}", - $"git.build.user.email={GitPropertiesFileWriter.EscapeLineBreaks(config.UserEmail)}", - $"git.tags={GitPropertiesFileWriter.EscapeLineBreaks(tagsAndCommitCount.Tags)}", - $"git.closest.tag.name={GitPropertiesFileWriter.EscapeLineBreaks(tagDescription.ClosestTagName)}", - $"git.closest.tag.commit.count={GitPropertiesFileWriter.EscapeLineBreaks(tagDescription.ClosestTagCommitCount)}", - $"git.remote.origin.url={GitPropertiesFileWriter.EscapeLineBreaks(config.RemoteUrl)}", - $"git.total.commit.count={GitPropertiesFileWriter.EscapeLineBreaks(tagsAndCommitCount.TotalCommitCount)}" + $"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)}" ]; try { - GitPropertiesFileWriter.WriteAtomic(CacheFile, lines); + AtomicFile.WriteAtomic(CacheFile, lines); } catch (IOException exception) { diff --git a/src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs b/src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs deleted file mode 100644 index e4a447e6ed..0000000000 --- a/src/Management/src/GitProperties.Build/GitPropertiesFileWriter.cs +++ /dev/null @@ -1,117 +0,0 @@ -// 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; - -internal static class GitPropertiesFileWriter -{ - /// - /// The git.properties key that writes and later searches for (to - /// append the "-dirty" suffix) - shared so the two can never silently drift out of sync with each other. - /// - public const string CommitIdDescribeKey = "git.commit.id.describe"; - - /// - /// Collapses real newlines to a literal "\n" so a value can never span multiple physical lines (Steeltoe's GitInfoContributor reads this file with - /// File.ReadAllLinesAsync and would silently truncate a value at the first embedded newline otherwise). Colons are deliberately left unescaped - - /// Steeltoe only unescapes "\:" back to ":", so leaving real colons alone (timestamps, URLs) is what round-trips correctly. - /// - public static string EscapeLineBreaks(string? value) - { - return value is null or "" ? string.Empty : value.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\\n"); - } - - /// - /// Writes to a per-process temp file then atomically moves it into place, matching the pattern MSBuild's own WriteLinesToFile task uses (since change - /// wave 18.3) to avoid a concurrent reader ever observing a torn/partial write when multiple projects in a solution build race to populate the same - /// shared cache file. - /// - public static void WriteAtomic(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); - File.WriteAllText(tempPath, $"{string.Join("\n", lines)}\n", encoding); - - Exception? lastError = null; - - for (int attempt = 1; attempt <= 3; attempt++) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - - File.Move(tempPath, path); - return; - } - catch (IOException exception) - { - lastError = exception; - Thread.Sleep(10); - } - } - - throw new IOException($"Failed to write {path}", lastError); - } - - /// - /// Attempts to acquire exclusive access to (creating it if needed) as a cross-process mutual-exclusion lock, retrying - /// for up to before giving up. Returns null on timeout, or if locking isn't possible at all in the current environment - - /// locking here is purely an optimization (avoiding redundant work when multiple projects/TFMs race to populate the same shared cache file), never a - /// correctness requirement, so any failure to acquire it must fall back to proceeding without one rather than failing the build. - /// - /// - /// Deliberately not a named : cross-process named Mutex/Semaphore support on Unix was added later than on Windows - /// and still carries real caveats (permissions, abandoned-lock semantics). Exclusive file access is a simpler, more universally portable primitive - - /// it's been supported identically on every OS .NET runs on since .NET Core 1.0 (implemented via ordinary POSIX advisory locking on Unix, not an - /// OS-specific IPC primitive), and an "abandoned" lock needs no special handling: if the holding process crashes, the OS releases the file handle - and - /// the lock - automatically on process exit. - /// - public static FileStream? TryAcquireExclusiveLock(string lockFilePath, TimeSpan timeout) - { - string? directory = Path.GetDirectoryName(lockFilePath); - - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - // S6354 (use an injectable time provider): not practical here - System.TimeProvider isn't - // available on netstandard2.0 without adding a package dependency purely for one retry-loop - // deadline in a synchronous MSBuild task assembly that has no other testability/DI pattern. -#pragma warning disable S6354 - DateTime deadlineUtc = DateTime.UtcNow + timeout; -#pragma warning restore S6354 - - while (true) - { - try - { - return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { -#pragma warning disable S6354 - if (DateTime.UtcNow >= deadlineUtc) -#pragma warning restore S6354 - { - return null; - } - - Thread.Sleep(50); - } - } - } -} diff --git a/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs b/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs new file mode 100644 index 0000000000..fd37fce301 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitPropertiesFormat.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; + +/// +/// Rules specific to the content/shape of a git.properties file. +/// +internal static class GitPropertiesFormat +{ + /// + /// The git.properties key that writes and later searches for (to + /// append the "-dirty" suffix) - shared so the two can never silently drift out of sync with each other. + /// + public const string CommitIdDescribeKey = "git.commit.id.describe"; + + /// + /// Collapses real newlines to a literal "\n" so a value can never span multiple physical lines (Steeltoe's GitInfoContributor reads this file with + /// File.ReadAllLinesAsync and would silently truncate a value at the first embedded newline otherwise). Colons are deliberately left unescaped - + /// Steeltoe only unescapes "\:" back to ":", so leaving real colons alone (timestamps, URLs) is what round-trips correctly. + /// + public static string EscapeLineBreaks(string? value) + { + return value is null or "" ? string.Empty : value.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\\n"); + } +} From 78c3e37aeceb8999b45cfed34efd69f929289c4a Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:21:13 +0200 Subject: [PATCH 03/20] Better error handling (full stack trace on unexpected failures) --- .../src/GitProperties.Build/AtomicFile.cs | 17 +++-- .../ComposeGitPropertiesTask.cs | 30 ++++++--- .../DetectConsumingPackageReferenceTask.cs | 7 +- .../FindGitRepositoryRootTask.cs | 6 +- .../GenerateGitPropertiesCacheTask.cs | 64 ++++++++++++++----- 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/src/Management/src/GitProperties.Build/AtomicFile.cs b/src/Management/src/GitProperties.Build/AtomicFile.cs index e28db56e00..e10fa00710 100644 --- a/src/Management/src/GitProperties.Build/AtomicFile.cs +++ b/src/Management/src/GitProperties.Build/AtomicFile.cs @@ -56,14 +56,14 @@ public static void WriteAtomic(string path, List lines) MoveOrReplace(tempPath, path); return; } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + catch (Exception exception) when (IsTransientError(exception)) { lastError = exception; Thread.Sleep(ReadWriteRetryDelay); } } - throw new IOException($"Failed to write {path}", lastError); + throw new IOException($"Failed to write {path} after {MaxAttempts} attempts.", lastError); } /// @@ -80,14 +80,14 @@ public static string[] ReadAllLinesWithRetry(string path) { return File.ReadAllLines(path); } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + catch (Exception exception) when (IsTransientError(exception)) { lastError = exception; Thread.Sleep(ReadWriteRetryDelay); } } - throw new IOException($"Failed to read {path}", lastError); + throw new IOException($"Failed to read {path} after {MaxAttempts} attempts.", lastError); } /// @@ -99,7 +99,7 @@ private static void MoveOrReplace(string sourcePath, string destinationPath) { File.Move(sourcePath, destinationPath); } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException && File.Exists(destinationPath)) + catch (IOException) when (File.Exists(destinationPath)) { File.Replace(sourcePath, destinationPath, null); } @@ -128,7 +128,7 @@ private static void MoveOrReplace(string sourcePath, string destinationPath) { return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + catch (Exception exception) when (IsTransientError(exception)) { if (CurrentTimeUtc >= deadlineUtc) { @@ -139,4 +139,9 @@ private static void MoveOrReplace(string sourcePath, string destinationPath) } } } + + 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 index bf00f2f8ea..a27645e22f 100644 --- a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -12,7 +12,9 @@ namespace Steeltoe.Management.GitProperties.Build; /// Merges the shared cache (see ) with the fields that can never be cached across projects: the live /// working-tree dirty state, the per-project $(Version), and the current build's own timestamp. Runs once per project, every build - unlike the cache /// generation, this is deliberately not skippable via Inputs/Outputs, since 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 by a second build. +/// incrementality off, and a cached build time would go stale the moment it's reused by a second build. Every failure below returns false: this task's +/// own output (and the fallback copy, if configured) must never end up mismatched with what's actually on disk, so a partial run must fail the build +/// loudly rather than let a later step (the copy-to-output/publish targets) pick up stale or incomplete content. /// // ReSharper disable once UnusedType.Global public sealed class ComposeGitPropertiesTask : Task @@ -57,7 +59,19 @@ public sealed class ComposeGitPropertiesTask : Task /// public override bool Execute() { - int exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, "status --porcelain", out string stdout, out string stderr); + int exitCode; + string stdout; + string stderr; + + try + { + exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, "status --porcelain", out stdout, out stderr); + } + catch (Exception exception) + { + Log.LogError($"git.properties: an unexpected error occurred while determining working tree status:{Environment.NewLine}{exception}"); + return false; + } if (exitCode != 0) { @@ -72,9 +86,9 @@ public override bool Execute() { lines = AtomicFile.ReadAllLinesWithRetry(CacheFile).ToList(); } - catch (IOException exception) + catch (Exception exception) { - Log.LogError($"git.properties: failed to read {CacheFile}: {exception.Message}"); + Log.LogError($"git.properties: failed to read {CacheFile}:{Environment.NewLine}{exception}"); return false; } @@ -103,9 +117,9 @@ public override bool Execute() { AtomicFile.WriteAtomic(OutputFile, lines); } - catch (IOException exception) + catch (Exception exception) { - Log.LogError($"git.properties: failed to write {OutputFile}: {exception.Message}"); + Log.LogError($"git.properties: failed to write {OutputFile}:{Environment.NewLine}{exception}"); return false; } @@ -118,9 +132,9 @@ public override bool Execute() { AtomicFile.WriteAtomic(FallbackFile, lines); } - catch (IOException exception) + catch (Exception exception) { - Log.LogError($"git.properties: failed to write fallback file {FallbackFile}: {exception.Message}"); + Log.LogError($"git.properties: failed to write fallback file {FallbackFile}:{Environment.NewLine}{exception}"); return false; } diff --git a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs index b576d6b533..d9d6ccf63a 100644 --- a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs +++ b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs @@ -60,9 +60,12 @@ public override bool Execute() string content = File.ReadAllText(ProjectAssetsFile); hasReference = ContainsAnyPackage(content); } - catch + catch (Exception exception) { - hasReference = false; + Log.LogError($"git.properties: failed to read '{ProjectAssetsFile}' while checking for a consuming package reference:" + + $"{Environment.NewLine}{exception}"); + + return false; } } diff --git a/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs index 9f911dda9c..24116d0408 100644 --- a/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs +++ b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs @@ -67,10 +67,10 @@ public override bool Execute() current = current.Parent; } } - catch + catch (Exception exception) { - repositoryRoot = string.Empty; - unsupportedGitFile = false; + Log.LogError($"git.properties: failed to walk up from '{StartDirectory}' looking for a git repository root:{Environment.NewLine}{exception}"); + return false; } RepositoryRoot = repositoryRoot; diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index 80785d3a01..5d780ad03d 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -34,6 +34,15 @@ public sealed class GenerateGitPropertiesCacheTask : Task /// private static readonly Version MinimumGitVersion = new(2, 15, 0); + /// + /// How long waits for the cross-process cache lock before giving up and generating the cache anyway (see + /// 's own remarks for why that fallback is always safe). Sized to comfortably cover a real, if slow, + /// cache generation - the ~10 sequential git calls that make up one full run of this method, even under heavy antivirus scanning or a large repository's + /// slower history-wide commands - without leaving every other concurrently-building project/TFM blocked for anywhere near as long if the holder is + /// actually stuck rather than just slow. + /// + private static readonly TimeSpan CacheLockTimeout = TimeSpan.FromSeconds(10); + /// /// 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. @@ -116,14 +125,22 @@ public override bool Execute() return true; } - string? commitId = Preflight(); + try + { + string? commitId = Preflight(); + + if (commitId == null) + { + return true; + } - if (commitId == null) + return TryGenerateAndWriteCache(commitId); + } + catch (Exception exception) { - return true; + Log.LogError($"git.properties: an unexpected error occurred while generating the shared cache:{Environment.NewLine}{exception}"); + return false; } - - return TryGenerateAndWriteCache(commitId); } /// @@ -166,8 +183,10 @@ private GitVersionStatus CheckGitVersion() } /// - /// Runs "git --version", reporting GITPROPS003 (forgivable) if git can't be invoked at all - either the process fails to start, or it starts and exits - /// with a non-zero code. Returns the raw output on success, or null in either failure case. + /// Runs "git --version", reporting GITPROPS003 (forgivable) if git can't be invoked at all - either the process fails to start (a thrown exception - the + /// most likely reason git.properties would ever be used at all: it isn't installed, or isn't on PATH) or it starts and exits with a non-zero code. Both + /// are routine, anticipated outcomes here specifically, unlike every other git invocation in this class - reported with just the immediate reason, not a + /// full exception dump. Returns the raw output on success, or null in either failure case. /// private string? GetGitVersion() { @@ -243,6 +262,9 @@ private GitVersionStatus CheckGitVersion() /// /// From here on, any failure is unexpected and fatal - git and the repository are already known-good (see ). + /// and the copy-to-output targets in Steeltoe.Management.GitProperties.Build.targets are gated on + /// merely existing, not on whether it's actually fresh - so a failure here must return false and stop the build, rather than + /// let those steps run against a stale cache left over from an earlier, successful run. /// /// /// Wrapped in a cross-process lock (see ): MSBuild's own Inputs/Outputs staleness check (in @@ -269,9 +291,17 @@ private bool TryGenerateAndWriteCache(string commitId) // later, and "delete, then someone else recreates the same path" is the classic TOCTOU race that breaks a // file-based mutex's mutual exclusion guarantee - simplest to just never delete it and let every build reuse // the same, already-existing lock file. - using FileStream? cacheLock = AtomicFile.TryAcquireExclusiveLock($"{CacheFile}.lock", TimeSpan.FromSeconds(30)); + using FileStream? cacheLock = AtomicFile.TryAcquireExclusiveLock($"{CacheFile}.lock", CacheLockTimeout); - if (cacheLock != null && WasCacheRewrittenWhileWaitingForLock(cacheWriteTimeBeforeLock)) + if (cacheLock == null) + { + // Purely informational: TryAcquireExclusiveLock's own contract is that failing to acquire it is never fatal, so this proceeds to regenerate + // the cache anyway - same as it always has - but a build that hits this on every single run (rather than as an occasional, expected race) is + // worth being able to spot from the log alone, without attaching a debugger. + 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 " + @@ -349,9 +379,9 @@ private bool TryGenerateAndWriteCache(string commitId) { AtomicFile.WriteAtomic(CacheFile, lines); } - catch (IOException exception) + catch (Exception exception) { - Log.LogError($"git.properties: failed to write {CacheFile}: {exception.Message}"); + Log.LogError($"git.properties: failed to write {CacheFile}:{Environment.NewLine}{exception}"); return false; } @@ -539,9 +569,9 @@ private int RunGit(string arguments, out string stdout, out string stderr) } /// - /// Reports a forgivable anomaly - either a full skip (GITPROPS001/003/004/005, where the caller has already decided to return false) or a - /// degraded-but-successful outcome (GITPROPS006, where generation still proceeds) - as a warning when is true, or a plain - /// informational message otherwise. + /// Reports a forgivable anomaly - either a full skip (GITPROPS001/003/004/005, where the caller has already decided to skip generation and return true) + /// or a degraded-but-successful outcome (GITPROPS006, where generation still proceeds) - as a warning when is true, or a + /// plain informational message otherwise. /// /// /// The downgraded message carries no code at all: a code only has a purpose when something is suppressible (via $(NoWarn)/ $(MSBuildWarningsAsErrors)), @@ -610,8 +640,10 @@ private enum GitVersionStatus Incompatible, /// - /// Git ran, but its "--version" output couldn't be parsed at all - already reported as a hard error via Log.LogError, since unlike "genuinely too old", - /// there's no safe default to fall back to here. + /// Git ran, but its "--version" output couldn't be parsed at all - a bug in 's own regex, or an unrecognized version + /// string shape, not a routine/anticipated condition like "compatible" or "incompatible". Unlike those two, there's no safe conclusion to draw here - we + /// genuinely don't know whether the installed git is usable - so this is reported as a hard error via Log.LogError and, unlike every other skip in this + /// class, stops the build rather than letting a stale cache (if one exists from an earlier run) get used regardless. /// Unknown } From 5541b47b313a3513944ed6df4e244ba072ed05f5 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:35:32 +0200 Subject: [PATCH 04/20] Fix broken macos build --- src/Management/test/GitProperties.Build.Test/TestPaths.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Management/test/GitProperties.Build.Test/TestPaths.cs b/src/Management/test/GitProperties.Build.Test/TestPaths.cs index 6fe0975559..e80bb45aca 100644 --- a/src/Management/test/GitProperties.Build.Test/TestPaths.cs +++ b/src/Management/test/GitProperties.Build.Test/TestPaths.cs @@ -67,7 +67,6 @@ internal static partial class TestPaths "shared-package.props", "shared-project.props", "versions.props", - "Directory.Build.targets", "stylecop.json", "PackageIcon.png", "PackageReadme.md", From eb6e7e69b435ff3a4199681acbc04a3ce87e4b41 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:30:22 +0200 Subject: [PATCH 05/20] Add test for git remotes --- .../GitPropertiesBuildTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs index ee34dfd0c8..2c856df071 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs @@ -258,6 +258,31 @@ public void GitFile_WarnsByDefault() featureOffResult.Output.Should().NotContain("GITPROPS002"); } + /// + /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a + /// fork's "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that + /// when origin itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config + /// --list"'s own last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). + /// + [Fact] + public void MultipleRemotes_OnlyOriginUrlIsUsed() + { + string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessRunner.RunGit(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); + ProcessRunner.RunGit(repository, "remote", "add", "origin", "https://example.com/origin.git"); + ProcessRunner.RunGit(repository, "remote", "set-url", "--add", "origin", "https://example.com/origin-second.git"); + + ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + AssertBuildSucceeded(result, "build with multiple remotes configured"); + + Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + + properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", + "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL."); + } + [Fact] public void ShallowClone_LeavesCommitCountsEmpty() { From 69377f469a2a3563bb07c63384c380201c5d0e01 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:52:11 +0200 Subject: [PATCH 06/20] Another macos fix (symlink temp path) --- .../GitPropertiesTestWorkspace.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index 086a9c0917..aa90b53dc0 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -30,8 +30,26 @@ internal sealed class GitPropertiesTestWorkspace : IDisposable public GitPropertiesTestWorkspace() { - RootDirectory = Path.Combine(Path.GetTempPath(), $"build-tasks-test_{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8]}"); - Directory.CreateDirectory(RootDirectory); + string rootDirectory = Path.Combine(Path.GetTempPath(), $"build-tasks-test_{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8]}"); + Directory.CreateDirectory(rootDirectory); + RootDirectory = ResolvePhysicalPath(rootDirectory); + } + + /// + /// On macOS, $TMPDIR resolves through a symlink (/var -> /private/var) that the OS silently canonicalizes away whenever a spawned process (git, + /// dotnet, MSBuild) reports its own working directory - e.g. in "git.properties: writing..." diagnostic messages. Resolving once up front here keeps + /// every path-based assertion in these tests (which compares against exactly that reported text) consistent with what a spawned process itself + /// reports, instead of the un-resolved alias $TMPDIR itself returns. + /// + private static string ResolvePhysicalPath(string path) + { + if (!OperatingSystem.IsMacOS()) + { + return path; + } + + ProcessResult result = ProcessRunner.Run("pwd", path, "-P"); + return result.Output.Trim(); } public void Dispose() From a0678ccdf73739e0ac8a15f92c1ff3763b8c7465 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:32:11 +0200 Subject: [PATCH 07/20] increase test coverage --- .../GitPropertiesBuildTests.cs | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs index 2c856df071..2a86ffe7b6 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs @@ -135,6 +135,13 @@ public void BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"], "nothing about the commit itself changed between the two builds."); } + /// + /// Also folds in coverage for the trickiest shape ParseDescribeOutput's dash-splitting has to get right - a tag name that itself contains a dash + /// ("release-1.0"), combined with a nonzero commits-ahead count - rather than spinning up a dedicated test just for that. Tagging an ANCESTOR of HEAD, + /// not HEAD itself, serves both purposes at once: it produces that nonzero count, and it keeps HEAD's own commit ID unchanged, which is the actual point + /// of this test's name - proving a new tag ref alone still invalidates the shared cache (see the regression this guards against in + /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's own remarks). + /// [Fact] public void NewTag_InvalidatesCache() { @@ -146,14 +153,22 @@ public void NewTag_InvalidatesCache() Dictionary propertiesBefore = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); propertiesBefore["git.tags"].Should().BeEmpty(); - ProcessResult tagResult = ProcessRunner.RunGit(repository, "tag", "v1.0.0"); + string ancestorCommitId = ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD~1"); + ProcessResult tagResult = ProcessRunner.RunGit(repository, "tag", "release-1.0", ancestorCommitId); tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); AssertBuildSucceeded(result2, "second build"); Dictionary propertiesAfter = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); - propertiesAfter["git.tags"].Should().Be("v1.0.0"); - propertiesAfter["git.commit.id.describe"].Should().Be("v1.0.0"); + + 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", "HEAD is exactly one commit ahead of the tagged ancestor."); + + // "release-1.0-1", not the raw "git describe" output ("release-1.0-1-g"): + // git.commit.id.describe deliberately omits the abbreviated SHA - see + // GenerateGitPropertiesCacheTask.ParseDescribeOutput's own BaseDescribe reconstruction. + propertiesAfter["git.commit.id.describe"].Should().Be("release-1.0-1"); } /// @@ -259,10 +274,12 @@ public void GitFile_WarnsByDefault() } /// - /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a - /// fork's "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that - /// when origin itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config - /// --list"'s own last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). + /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a fork's + /// "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that when origin + /// itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config --list"'s own + /// last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). The winning URL is + /// deliberately given embedded credentials, folding StripUserInfo's own coverage into this same build rather than spinning up a dedicated test just for + /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. /// [Fact] public void MultipleRemotes_OnlyOriginUrlIsUsed() @@ -272,7 +289,7 @@ public void MultipleRemotes_OnlyOriginUrlIsUsed() ProcessRunner.RunGit(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); ProcessRunner.RunGit(repository, "remote", "add", "origin", "https://example.com/origin.git"); - ProcessRunner.RunGit(repository, "remote", "set-url", "--add", "origin", "https://example.com/origin-second.git"); + ProcessRunner.RunGit(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); AssertBuildSucceeded(result, "build with multiple remotes configured"); @@ -280,7 +297,8 @@ public void MultipleRemotes_OnlyOriginUrlIsUsed() Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", - "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL."); + "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + + "'user:pass@' credentials must be stripped before the value ever reaches the cache file."); } [Fact] From 6614990bbb6747efb88922ba613b27551f8e7b0c Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:28:57 +0200 Subject: [PATCH 08/20] Speed up test runs, cancel immediately on stop --- .../GitPropertiesBuildTestBase.cs | 90 ++ .../GitPropertiesBuildTests.cs | 791 ++++++++++-------- .../GitPropertiesTestWorkspace.cs | 141 ++-- .../GitProperties.Build.Test/ProcessRunner.cs | 134 ++- .../PropertiesFile.cs | 4 +- .../GitProperties.Build.Test/TestPaths.cs | 89 +- 6 files changed, 787 insertions(+), 462 deletions(-) create mode 100644 src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs 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..82d53b531d --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs @@ -0,0 +1,90 @@ +// 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/assertion plumbing for every test in this project. Split one test per class - rather than many [Fact] methods on one shared +/// class - purely for parallelism: xUnit v3 runs different test classes concurrently by default (no configuration or version upgrade needed - verified +/// empirically), but never parallelizes methods within the SAME class. Since every test here is dominated by "dotnet build"/"publish" subprocess time +/// that's mostly I/O/wait-bound, not CPU-bound (measured: 4 concurrent builds complete in ~1.3x one build's time, 8 concurrent in ~1.9x), +/// one-class-per-test lets the whole suite's wall-clock approach its slowest single test instead of the sum of all of them. +/// +/// +/// Public (xUnit only discovers public test classes), but every member below is internal rather than protected: ProcessResult and +/// GitPropertiesTestWorkspace are themselves internal, and a protected member of a public class can't expose a less-accessible type in its signature +/// (CS0051/CS0052). Internal still works the same way for every derived class here, since they all live in this same assembly. Implements IAsyncLifetime +/// (not a constructor) to create : GitPropertiesTestWorkspace.CreateAsync itself awaits a "pwd -P" subprocess on macOS to +/// resolve a symlink-free root path, and a constructor can't await. +/// +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; + } + + internal static async Task>> GetGitPropertiesPerTargetFrameworkAsync(string projectDirectory, string[] frameworks) + { + List> result = []; + + foreach (string framework in frameworks) + { + Dictionary properties = await PropertiesFile.ReadAsync(Path.Combine(projectDirectory, "bin", "Debug", framework, "git.properties")); + result.Add(properties); + } + + return result; + } + + internal static string GetFallbackFilePath(string projectDirectory) + { + return Path.Combine(projectDirectory, "git.properties"); + } + + internal static void AssertBuildSucceeded(ProcessResult result, string action) + { + result.ExitCode.Should().Be(0, "{0} should succeed. Output:\n{1}", action, result.Output); + } + + internal static void AssertWarned(ProcessResult result, string code) + { + result.Output.Should().Contain($"warning {code}"); + } + + /// + /// GitPropertiesEnableWarnings=false downgrades a diagnostic from a Warning to a plain informational message - with no code at all (see + /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why), and at Importance="Normal" rather than the default's "high", so it's visible at + /// "-v:normal" but not in default build output. + /// + internal static void AssertReportedAsInfoOnly(ProcessResult result, string code, string messageSnippet) + { + result.Output.Should().NotContain(code, "a downgraded message must never carry a code - only warnings do."); + result.Output.Should().Contain(messageSnippet); + } + + internal static void AssertNoGitPropertiesGenerated(string projectDirectory) + { + File.Exists(GetDebugGitPropertiesFilePath(projectDirectory)).Should().BeFalse("no git.properties should be generated."); + } + + internal static string GetDebugGitPropertiesFilePath(string projectDirectory) + { + return Path.Combine(projectDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + } + + internal static string GetReleasePublishGitPropertiesFilePath(string projectDirectory) + { + return Path.Combine(projectDirectory, "bin", "Release", TestPaths.TestAppTargetFramework, "publish", "git.properties"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs index 2a86ffe7b6..8a511f2550 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs @@ -5,52 +5,86 @@ using System.Globalization; using System.Text.RegularExpressions; -#pragma warning disable S2925 // "Thread.Sleep" should not be used in tests - namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// Automated regression tests for the Steeltoe.Management.GitProperties.Build project (build/Steeltoe.Management.GitProperties.Build.targets plus its -/// compiled MSBuild tasks). Exercises the scenarios that were manually verified while building this feature: ground-truth property values, incremental -/// cache behavior, publish (with and without a prior build), the warn/info/skip diagnostic paths, shallow clones, non-ASCII commit data, and -/// cross-project cache sharing. Every test runs against an isolated temporary workspace containing the CURRENT source of -/// Steeltoe.Management.GitProperties.Build (see GitPropertiesTestWorkspace), not a stale git-tracked copy, so it always exercises whatever is on disk -/// right now. Every git repository a test operates against is a small, synthetic one created from scratch (`git init` plus a handful of manufactured -/// commits) - never a clone of this (large, real) repository - so the suite stays fast. Nothing here touches this repository's own working tree. -/// -public sealed class GitPropertiesBuildTests : IDisposable +// Automated regression tests for the Steeltoe.Management.GitProperties.Build project +// (build/Steeltoe.Management.GitProperties.Build.targets plus its compiled MSBuild tasks). Exercises the +// scenarios that were manually verified while building this feature: ground-truth property values, +// incremental cache behavior, publish (with and without a prior build), the warn/info/skip diagnostic +// paths, shallow clones, non-ASCII commit data, and cross-project cache sharing. Every test runs against +// an isolated temporary workspace containing the CURRENT source of Steeltoe.Management.GitProperties.Build +// (see GitPropertiesTestWorkspace), not a stale git-tracked copy, so it always exercises whatever is on +// disk right now. Every git repository a test operates against is a small, synthetic one created from +// scratch (`git init` plus a handful of manufactured commits) - never a clone of this (large, real) +// repository - so the suite stays fast. Nothing here touches this repository's own working tree. +// +// One class per test (see GitPropertiesBuildTestBase's own remarks for why) rather than many [Fact] +// methods on one shared class: xUnit v3 runs different test classes concurrently by default, but never +// parallelizes methods within the same class. Every test here is dominated by "dotnet build"/"publish" +// subprocess time that's mostly I/O/wait-bound, not CPU-bound - splitting this way lets the whole suite's +// wall-clock approach its single slowest test instead of the sum of all of them. +// +// Measured (TRX per-test timing, sequential run): every test costs roughly 3.7-4.2 seconds PER "dotnet +// build"/"publish" subprocess it spawns, almost regardless of git-repository complexity - even the two +// tests with no git repository at all still cost ~3.7s each, matching the single-build cases with a real +// repository. Git setup (git init, commits, tags, config) is comparatively free by contrast. When a new +// scenario needs only one extra assertion against a plain default build, prefer folding it into an +// existing test that already builds one (see GroundTruthAllPropertiesMatchGitTest's own remarks for an +// example) over adding a dedicated test that pays for another subprocess just to re-run the identical +// setup. +public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTestBase { - private static readonly Regex NuPkgVersionRegex = - new($@"^{Regex.Escape(TestPaths.PackageId)}\.(.+)\.nupkg$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - - private readonly GitPropertiesTestWorkspace _workspace = new(); - + /// + /// Also folds in two other checks against this same build, rather than paying for a dedicated "dotnet build" subprocess (by far the dominant cost of any + /// test in this suite - see the class remarks) just to exercise a single extra assertion against an otherwise identical, plain default build: that the + /// fallback file is never written unless explicitly opted into (see WriteToProjectDirectoryCreatesFallbackFileOnBuildTest for the positive case, which - + /// unlike this one - genuinely needs its own build, since it passes a different property), and that writing git.properties is confirmed at default + /// verbosity. + /// [Fact] - public void GroundTruth_AllPropertiesMatchGit() + public async Task GroundTruth_AllPropertiesMatchGit() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 3); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 3); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result, "build"); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + File.Exists(GetFallbackFilePath(testApp)).Should().BeFalse( + "the fallback file must not be written into the project directory unless explicitly opted into."); + + string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + result.Output.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); - properties["git.commit.id.abbrev"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "--short=7", "HEAD")); - properties["git.commit.user.name"].Should().Be(ProcessRunner.GetGitOutput(repository, "log", "-1", "--format=%an")); - properties["git.commit.user.email"].Should().Be(ProcessRunner.GetGitOutput(repository, "log", "-1", "--format=%ae")); - properties["git.commit.message.short"].Should().Be(ProcessRunner.GetGitOutput(repository, "log", "-1", "--format=%s")); - properties["git.total.commit.count"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-list", "--count", "HEAD")); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); - bool expectedDirty = ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Length > 0; + string expectedCommitIdAbbrev = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "--short=7", "HEAD"); + properties["git.commit.id.abbrev"].Should().Be(expectedCommitIdAbbrev); + + string expectedCommitUserName = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%an"); + properties["git.commit.user.name"].Should().Be(expectedCommitUserName); + + string expectedCommitUserEmail = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%ae"); + properties["git.commit.user.email"].Should().Be(expectedCommitUserEmail); + + string expectedCommitMessageShort = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%s"); + properties["git.commit.message.short"].Should().Be(expectedCommitMessageShort); + + string expectedTotalCommitCount = await ProcessRunner.GetGitOutputAsync(repository, "rev-list", "--count", "HEAD"); + properties["git.total.commit.count"].Should().Be(expectedTotalCommitCount); + + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + bool expectedDirty = gitStatus.Length > 0; 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."); + 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), "git.build.time must reflect roughly when this build actually ran."); @@ -80,51 +114,61 @@ public void GroundTruth_AllPropertiesMatchGit() properties.Keys.Should().BeEquivalentTo(expectedKeys); } +} +public sealed class IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropertiesBuildTestBase +{ [Fact] - public void IncrementalBuild_CacheSkipsButDirtyStaysLive() + public async Task IncrementalBuild_CacheSkipsButDirtyStaysLive() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result1, "first build"); string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); File.Exists(cacheFile).Should().BeTrue("the cache file should exist after first build."); - DateTime writeTimeBefore = File.GetLastWriteTimeUtc(cacheFile); - // Ensure a rewritten file would get a detectably different modified-time. - Thread.Sleep(1100); - ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build", "-v:detailed"); + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); AssertBuildSucceeded(result2, "second build"); - result2.Output.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); - File.GetLastWriteTimeUtc(cacheFile).Should().Be(writeTimeBefore, "nothing git-relevant changed, so the cache file must not be rewritten."); + // "Skipping target" is itself the deterministic, sufficient proof that nothing rewrote the + // cache file on this second build - no last-write-time comparison (and the sleep it would + // otherwise need, to guarantee a detectably different timestamp) is needed on top of it. + result2.Output.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); - PropertiesFile.Read(DebugGitPropertiesFile(testApp)).Should().ContainKey("git.dirty"); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + properties.Should().ContainKey("git.dirty"); } +} +public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitPropertiesBuildTestBase +{ /// /// Guards against git.build.time accidentally ending up in the shared, cross-project/cross-TFM cache (see GenerateGitPropertiesCacheTask) instead of - /// being recomputed by ComposeGitPropertiesTask on every build - same class of regression IncrementalBuild_CacheSkipsButDirtyStaysLive guards against + /// being recomputed by ComposeGitPropertiesTask on every build - same class of regression IncrementalBuildCacheSkipsButDirtyStaysLiveTest guards against /// for git.dirty. A cached build time would go stale (reporting the FIRST build's time on every subsequent one), silently defeating the whole point of /// the field: telling you when THIS build actually ran. /// [Fact] - public void BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() + public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result1, "first build"); - Dictionary propertiesBefore = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + // git.build.time is formatted "yyyy-MM-ddTHH:mm:sszzz" (ComposeGitPropertiesTask) - second + // resolution only, no fractional part - so two builds landing within the same wall-clock + // second would produce identical values no matter how this test is written. This delay is + // sized to that format's own precision, not incidental slack. + await Task.Delay(TimeSpan.FromMilliseconds(1100), TestContext.Current.CancellationToken); - // Ensure a live-recomputed build time would get a detectably different value. - Thread.Sleep(1100); - ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result2, "second build"); - Dictionary propertiesAfter = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"], "git.build.time must be recomputed on every build, not reused from the shared cache."); @@ -134,7 +178,10 @@ public void BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"], "nothing about the commit itself changed between the two builds."); } +} +public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase +{ /// /// Also folds in coverage for the trickiest shape ParseDescribeOutput's dash-splitting has to get right - a tag name that itself contains a dash /// ("release-1.0"), combined with a nonzero commits-ahead count - rather than spinning up a dedicated test just for that. Tagging an ANCESTOR of HEAD, @@ -143,23 +190,23 @@ public void BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's own remarks). /// [Fact] - public void NewTag_InvalidatesCache() + public async Task NewTag_InvalidatesCache() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result1, "first build"); - Dictionary propertiesBefore = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); propertiesBefore["git.tags"].Should().BeEmpty(); - string ancestorCommitId = ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD~1"); - ProcessResult tagResult = ProcessRunner.RunGit(repository, "tag", "release-1.0", ancestorCommitId); + string ancestorCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD~1"); + ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "release-1.0", ancestorCommitId); tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); - ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result2, "second build"); - Dictionary propertiesAfter = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); 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"); @@ -170,109 +217,109 @@ public void NewTag_InvalidatesCache() // GenerateGitPropertiesCacheTask.ParseDescribeOutput's own BaseDescribe reconstruction. propertiesAfter["git.commit.id.describe"].Should().Be("release-1.0-1"); } +} - /// - /// Unlike every GITPROPS0xx diagnostic (all either $(GitPropertiesEnableWarnings)-gated or left at Message's own default importance because they - /// describe an anomaly, not the happy path), confirmation that git.properties was actually written is unconditional and at High importance - visible in - /// default build output with no extra verbosity flag needed - because it's the one concrete artifact this whole feature exists to produce. Logged from - /// Steeltoe.Management.GitProperties.Build.targets (via $(MSBuildProjectName)), not from ComposeGitPropertiesTask itself - a Task has no built-in notion - /// of "which project is this", so a solution build with many projects would otherwise be unable to tell which one this line belongs to. - /// - [Fact] - public void ComposeGitProperties_LogsWrittenFileAtDefaultVerbosity() - { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); - AssertBuildSucceeded(result, "build"); - - string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - result.Output.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - } - +public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBase +{ [Fact] - public void Publish_IncludesGitProperties() + public async Task Publish_IncludesGitProperties() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "publish"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish"); AssertBuildSucceeded(result, "publish"); result.Output.Should().NotContain("duplicate"); - Dictionary properties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); } +} +public sealed class PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuildTestBase +{ [Fact] - public void Publish_NoBuild_IncludesGitProperties() + public async Task Publish_NoBuild_IncludesGitProperties() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult buildResult = ProcessRunner.RunDotnet(testApp, "build", "-c", "Release"); + ProcessResult buildResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-c", "Release"); AssertBuildSucceeded(buildResult, "build"); - ProcessResult publishResult = ProcessRunner.RunDotnet(testApp, "publish", "-c", "Release", "--no-build"); + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-c", "Release", "--no-build"); AssertBuildSucceeded(publishResult, "publish --no-build"); publishResult.Output.Should().NotContain("duplicate"); - Dictionary properties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); } +} +public sealed class NoGitWarnsByDefaultTest : GitPropertiesBuildTestBase +{ [Fact] - public void NoGit_WarnsByDefault() + public async Task NoGit_WarnsByDefault() { - string projectDirectory = Path.Combine(_workspace.RootDirectory, "proj"); + string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); Directory.CreateDirectory(projectDirectory); - string testApp = _workspace.CopyCurrentProjectFiles(projectDirectory); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result, "the build with no .git present"); AssertWarned(result, "GITPROPS001"); AssertNoGitPropertiesGenerated(testApp); } +} +public sealed class NoGitInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase +{ [Fact] - public void NoGit_InfoWhenEnableWarningsFalse() + public async Task NoGit_InfoWhenEnableWarningsFalse() { - string projectDirectory = Path.Combine(_workspace.RootDirectory, "proj"); + string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); Directory.CreateDirectory(projectDirectory); - string testApp = _workspace.CopyCurrentProjectFiles(projectDirectory); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertBuildSucceeded(result, "build"); AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); AssertNoGitPropertiesGenerated(testApp); } +} +public sealed class GitFileWarnsByDefaultTest : GitPropertiesBuildTestBase +{ [Fact] - public void GitFile_WarnsByDefault() + public async Task GitFile_WarnsByDefault() { - string projectDirectory = Path.Combine(_workspace.RootDirectory, "proj"); + string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); Directory.CreateDirectory(projectDirectory); - string testApp = _workspace.CopyCurrentProjectFiles(projectDirectory); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk // (which starts at TestApp, the project actually being built) to find it - i.e. at // projectDirectory itself. - File.WriteAllText(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj"); + await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj", TestContext.Current.CancellationToken); - ProcessResult defaultResult = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(defaultResult, "the build with .git as a file (a worktree/submodule checkout - e.g. an AI agent - must never fail)"); AssertWarned(defaultResult, "GITPROPS002"); AssertNoGitPropertiesGenerated(testApp); - ProcessResult enableWarningsFalseResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + ProcessResult enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertBuildSucceeded(enableWarningsFalseResult, "build"); AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS002", "resolves to a git worktree or submodule"); - ProcessResult featureOffResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GenerateGitProperties=false"); + ProcessResult featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); AssertBuildSucceeded(featureOffResult, "build with GenerateGitProperties=false"); featureOffResult.Output.Should().NotContain("GITPROPS002"); } +} +public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildTestBase +{ /// /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a fork's /// "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that when origin @@ -282,160 +329,174 @@ public void GitFile_WarnsByDefault() /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. /// [Fact] - public void MultipleRemotes_OnlyOriginUrlIsUsed() + public async Task MultipleRemotes_OnlyOriginUrlIsUsed() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessRunner.RunGit(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); - ProcessRunner.RunGit(repository, "remote", "add", "origin", "https://example.com/origin.git"); - ProcessRunner.RunGit(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); + await ProcessRunner.RunGitAsync(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); + await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "https://example.com/origin.git"); + await ProcessRunner.RunGitAsync(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result, "build with multiple remotes configured"); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + "'user:pass@' credentials must be stripped before the value ever reaches the cache file."); } +} +public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuildTestBase +{ [Fact] - public void ShallowClone_LeavesCommitCountsEmpty() + public async Task ShallowClone_LeavesCommitCountsEmpty() { - string source = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "source"), 3); - ProcessRunner.RunGit(source, "tag", "v1.0.0"); + string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 3); + await ProcessRunner.RunGitAsync(source, "tag", "v1.0.0"); - string shallow = Path.Combine(_workspace.RootDirectory, "shallow"); + string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); // --no-local is required here: for a plain local filesystem path, git's local-clone // optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, // producing a full clone that would make this test worthless. - ProcessResult cloneResult = ProcessRunner.RunGit(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); + ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); - ProcessRunner.GetGitOutput(shallow, "rev-parse", "--is-shallow-repository").Should().Be("true"); + string isShallowRepository = await ProcessRunner.GetGitOutputAsync(shallow, "rev-parse", "--is-shallow-repository"); + isShallowRepository.Should().Be("true"); - string testApp = _workspace.CopyCurrentProjectFiles(shallow); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result, "the build against a shallow clone"); result.Output.Should().NotContain("GITPROPS001"); result.Output.Should().NotContain("GITPROPS002"); AssertWarned(result, "GITPROPS006"); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); properties["git.total.commit.count"].Should().BeEmpty(); properties["git.closest.tag.commit.count"].Should().BeEmpty(); } +} +public sealed class ShallowCloneInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase +{ /// /// GITPROPS006 (unlike GITPROPS001-005) never blocks generation - the shallow clone is still fully usable, just with two fields left empty (see - /// ). Confirms $(GitPropertiesEnableWarnings) downgrades it to an informational message the same way - /// it does for the others. + /// ). Confirms $(GitPropertiesEnableWarnings) downgrades it to an informational message the same + /// way it does for the others. /// [Fact] - public void ShallowClone_InfoWhenEnableWarningsFalse() + public async Task ShallowClone_InfoWhenEnableWarningsFalse() { - string source = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "source"), 1); + string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 1); - string shallow = Path.Combine(_workspace.RootDirectory, "shallow"); - ProcessResult cloneResult = ProcessRunner.RunGit(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); + string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); + ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); - string testApp = _workspace.CopyCurrentProjectFiles(shallow); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertBuildSucceeded(result, "build"); AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); } +} +public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildTestBase +{ [Fact] - public void NonAscii_CommitDataRendersCorrectly() + public async Task NonAscii_CommitDataRendersCorrectly() { - string repository = Path.Combine(_workspace.RootDirectory, "repo"); + string repository = Path.Combine(Workspace.RootDirectory, "repo"); Directory.CreateDirectory(repository); - ProcessRunner.RunGit(repository, "init", "--quiet", "--initial-branch=main", "."); + await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). const string nonAsciiUserName = "\u00DCn\u00EFc\u00F6d\u00E9 T\u00EBst"; const string nonAsciiCommitMessage = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; - ProcessRunner.RunGit(repository, "config", "user.name", nonAsciiUserName); - ProcessRunner.RunGit(repository, "config", "user.email", "test@example.com"); - File.WriteAllText(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n"); - File.WriteAllText(Path.Combine(repository, "file.txt"), "content"); - ProcessRunner.RunGit(repository, "add", "-A"); - ProcessRunner.RunGit(repository, "commit", "--quiet", "-m", nonAsciiCommitMessage); + await ProcessRunner.RunGitAsync(repository, "config", "user.name", nonAsciiUserName); + await ProcessRunner.RunGitAsync(repository, "config", "user.email", "test@example.com"); + await File.WriteAllTextAsync(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(repository, "file.txt"), "content", TestContext.Current.CancellationToken); + await ProcessRunner.RunGitAsync(repository, "add", "-A"); + await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitMessage); - string testApp = _workspace.CopyCurrentProjectFiles(repository); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result, "build"); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); properties["git.commit.user.name"].Should().Be(nonAsciiUserName); properties["git.commit.message.short"].Should().Be(nonAsciiCommitMessage); } +} +public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase +{ [Fact] - public void MultiProject_SharesCache() + public async Task MultiProject_SharesCache() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 2); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2); // Two independent projects at the repo root, each with their own ProjectReference/Import // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepo - // already placed TestApp there; reuse that exact relative layout for ProjA/ProjB by placing them + // already placed TestApp there; reuse that exact relative layout for ProjectA/ProjectB by placing them // at the repo root too, siblings of TestApp and "src"). - GitPropertiesTestWorkspace.WriteAppProject(repository, "ProjA"); - GitPropertiesTestWorkspace.WriteAppProject(repository, "ProjB"); + await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectA"); + await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectB"); - string projA = Path.Combine(repository, "ProjA"); - string projB = Path.Combine(repository, "ProjB"); + string projectA = Path.Combine(repository, "ProjectA"); + string projectB = Path.Combine(repository, "ProjectB"); - ProcessResult resultA = ProcessRunner.RunDotnet(projA, "build", "-v:detailed"); - AssertBuildSucceeded(resultA, "ProjA build"); + ProcessResult resultA = await ProcessRunner.RunDotnetAsync(projectA, "build", "-v:detailed"); + AssertBuildSucceeded(resultA, "ProjectA build"); resultA.Output.Should().Contain("git.properties: generating shared cache", - "ProjA (first to build) should be the one that actually generates the shared cache."); + "ProjectA (first to build) should be the one that actually generates the shared cache."); string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); - File.Exists(cacheFile).Should().BeTrue("ProjA's build should have generated the shared cache."); - DateTime cacheWriteTimeAfterA = File.GetLastWriteTimeUtc(cacheFile); - - // Ensure a rewritten file would get a detectably different modified-time. - Thread.Sleep(1100); + File.Exists(cacheFile).Should().BeTrue("ProjectA's build should have generated the shared cache."); - ProcessResult resultB = ProcessRunner.RunDotnet(projB, "build", "-v:detailed"); - AssertBuildSucceeded(resultB, "ProjB build"); - resultB.Output.Should().NotContain("git.properties: generating shared cache", "ProjB should reuse ProjA's cache instead of regenerating it."); + ProcessResult resultB = await ProcessRunner.RunDotnetAsync(projectB, "build", "-v:detailed"); + AssertBuildSucceeded(resultB, "ProjectB build"); - File.GetLastWriteTimeUtc(cacheFile).Should().Be(cacheWriteTimeAfterA, "ProjB must not have rewritten the shared cache file."); + // "did not log generating the cache" is itself the deterministic, sufficient proof ProjectB + // reused ProjectA's cache instead of rewriting it - no last-write-time comparison (and the + // sleep it would otherwise need) is needed on top of it. + resultB.Output.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); - Dictionary propertiesA = PropertiesFile.Read(DebugGitPropertiesFile(projA)); - Dictionary propertiesB = PropertiesFile.Read(DebugGitPropertiesFile(projB)); + Dictionary propertiesA = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectA)); + Dictionary propertiesB = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectB)); propertiesB["git.commit.id"].Should().Be(propertiesA["git.commit.id"]); } +} +public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : GitPropertiesBuildTestBase +{ /// /// A single multi-targeted project (current TFM plus the one immediately before it - see ) is a - /// different sharing scenario than : MSBuild builds a multi-targeted project's inner TFMs concurrently by default - /// (unlike the two sequential "dotnet build" invocations that test uses), which is exactly the race + /// different sharing scenario than : MSBuild builds a multi-targeted project's inner TFMs concurrently by + /// default (unlike the two sequential "dotnet build" invocations that test uses), which is exactly the race /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's cross-process lock exists to handle. Also guards against the regression that fix's first /// (wrong) attempt introduced: tagging the current commit invalidates the cache without changing the commit ID, so a naive "does the cache already /// reflect this commit" freshness check would wrongly skip regenerating it. /// [Fact] - public void MultiTargetedProject_SharesCacheAcrossTargetFrameworks() + public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); - ProcessResult result1 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result1, "multi-targeted build"); - string expectedCommitId = ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD"); - Dictionary[] propertiesBefore = ReadPropertiesForEachFramework(testApp, frameworks); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + List> propertiesBefore = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); foreach (Dictionary properties in propertiesBefore) { @@ -443,86 +504,87 @@ public void MultiTargetedProject_SharesCacheAcrossTargetFrameworks() properties["git.tags"].Should().BeEmpty(); } - ProcessResult tagResult = ProcessRunner.RunGit(repository, "tag", "v1.0.0"); + ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "v1.0.0"); tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); - ProcessResult result2 = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result2, "second multi-targeted build"); - Dictionary[] propertiesAfter = ReadPropertiesForEachFramework(testApp, frameworks); + List> propertiesAfter = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); foreach (Dictionary properties in propertiesAfter) { properties["git.tags"].Should().Be("v1.0.0", "both target frameworks must observe the new tag, even though the commit it points at didn't change."); } } +} +public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitPropertiesBuildTestBase +{ [Fact] - public void WriteToProjectDirectory_DefaultsToOff() - { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); - AssertBuildSucceeded(result, "build"); - - File.Exists(FallbackFile(testApp)).Should().BeFalse("the fallback file must not be written into the project directory unless explicitly opted into."); - } - - [Fact] - public void WriteToProjectDirectory_CreatesFallbackFile_OnBuild() + public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result, "build with GitPropertiesWriteToProjectDirectory=true"); + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result1, "build with GitPropertiesWriteToProjectDirectory=true"); - result.Output.Should() - .Contain($"git.properties: writing fallback copy to '{FallbackFile(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + result1.Output.Should().Contain( + $"git.properties: writing fallback copy to '{GetFallbackFilePath(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - File.Exists(FallbackFile(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); - Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); - Dictionary outputProperties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); - fallbackProperties.Should().BeEquivalentTo(outputProperties, "the fallback file must carry the exact same content as the live build output."); + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + Dictionary outputProperties1 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + fallbackProperties.Should().BeEquivalentTo(outputProperties1, "the fallback file must carry the exact same content as the live build output."); - ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Should() - .BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. - ProcessResult secondResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(secondResult, "second build"); + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result2, "second build"); - PropertiesFile.Read(DebugGitPropertiesFile(testApp))["git.dirty"].Should().Be("false", + Dictionary outputProperties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + outputProperties2["git.dirty"].Should().Be("false", "the gitignored fallback file from the first build must not make a later build see the tree as dirty."); } +} +public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest : GitPropertiesBuildTestBase +{ /// - /// The negative counterpart to - proves the README's ".gitignore this file" warning + /// The negative counterpart to - proves the README's ".gitignore this file" warning /// is describing a real consequence, not a hypothetical one: deliberately uses a repository WITHOUT the fallback file gitignored, so the file the first /// build writes is left behind as a genuine untracked change - permanently flipping git.dirty to "true" on every later build, even though nothing about /// the actually-tracked source changed in between. /// [Fact] - public void FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() + public async Task FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult firstResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(firstResult, "first build, which writes the (not yet gitignored) fallback file"); + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result1, "first build, which writes the (not yet gitignored) fallback file"); + + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + gitStatus.Should().NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); - ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Should() - .NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result2, "second build"); - ProcessResult secondResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(secondResult, "second build"); + Dictionary properties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - PropertiesFile.Read(DebugGitPropertiesFile(testApp))["git.dirty"].Should().Be("true", + properties2["git.dirty"].Should().Be("true", "the ungitignored fallback file left over from the first build makes every later build see the tree as dirty."); } +} +public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : GitPropertiesBuildTestBase +{ /// /// "dotnet publish" runs its own compile/composition steps internally regardless of whether "dotnet build" ran first - this guards against the fallback /// file only being written along the "build" target chain and silently never firing when publish is the very first command run against a fresh checkout @@ -531,49 +593,57 @@ public void FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() /// is available here, nothing should be skipped (and no GITPROPS0xx code should appear) regardless of that setting. /// [Fact] - public void WriteToProjectDirectory_CreatesFallbackFile_OnPublish() + public async Task WriteToProjectDirectory_CreatesFallbackFile_OnPublish() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = - ProcessRunner.RunDotnet(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", + "-p:GitPropertiesEnableWarnings=true"); AssertBuildSucceeded(result, "publish with GitPropertiesWriteToProjectDirectory=true, without an upfront build"); result.Output.Should().NotContain("GITPROPS0", "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); - File.Exists(FallbackFile(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); - Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); - Dictionary publishedProperties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(testApp)); + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); fallbackProperties.Should().BeEquivalentTo(publishedProperties, "the fallback file must carry the exact same content as the published output."); - ProcessRunner.GetGitOutput(repository, "status", "--porcelain").Should() - .BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); } +} +public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesBuildTestBase +{ /// /// Guards against a stale fallback file (left over from some earlier build) ever shadowing live generation - the fallback file must only ever be used as /// a last resort, never preferred over a real, currently-usable .git repository. /// [Fact] - public void FallbackFile_Ignored_WhenLiveGitAvailable() + public async Task FallbackFile_Ignored_WhenLiveGitAvailable() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - File.WriteAllLines(FallbackFile(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]); + await File.WriteAllLinesAsync(GetFallbackFilePath(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], + TestContext.Current.CancellationToken); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-v:detailed"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); AssertBuildSucceeded(result, "build with a stale fallback file present alongside a real .git repository"); result.Output.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); } +} +public sealed class FallbackFileUsedWhenNoGitAvailableTest : GitPropertiesBuildTestBase +{ /// /// End-to-end simulation of the scenario that motivated $(GitPropertiesWriteToProjectDirectory) in the first place: `cf push` using the /// dotnet_core_buildpack directly from source, which strips ".git" from the pushed tree unconditionally (see SimulateSourcePush) - meaning live @@ -581,57 +651,64 @@ public void FallbackFile_Ignored_WhenLiveGitAvailable() /// along in the pushed source tree and get picked up, ending up in the published output exactly as if it had been generated live. /// [Fact] - public void FallbackFile_UsedWhenNoGitAvailable() + public async Task FallbackFile_UsedWhenNoGitAvailable() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 2, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult fallbackResult = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + ProcessResult fallbackResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); AssertBuildSucceeded(fallbackResult, "the local build that produces the fallback file"); - Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); fallbackProperties["git.dirty"].Should().Be("false", "the gitignored fallback file must not make its own producing build see the tree as dirty."); - string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(_workspace.RootDirectory, "pushed")); + string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); - File.Exists(FallbackFile(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - ProcessResult publishResult = ProcessRunner.RunDotnet(pushedApp, "publish", "-v:detailed"); + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); publishResult.Output.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); - publishResult.Output.Should() - .Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + publishResult.Output.Should().Contain( + "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - Dictionary publishedProperties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(pushedApp)); + Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); } +} +public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest : GitPropertiesBuildTestBase +{ /// /// The stable, documented entry point for step 1 of the "Recommended cf push workflow" (see PackageReadme.md) - confirms it actually produces a usable /// fallback file, and that doing so never compiles anything (the whole reason to prefer it over a full "dotnet build" before a source push). /// [Fact] - public void WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() + public async Task WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); AssertBuildSucceeded(result, "build -t:WriteGitPropertiesFallbackFile"); - File.Exists(FallbackFile(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); - Dictionary properties = PropertiesFile.Read(FallbackFile(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + Dictionary properties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); // "bin\Debug\\publish" gets created as empty, routine SDK scaffolding even here (PrepareForPublish's own setup) - checking for the absence // of the compiled assembly itself, not just the bin directory, is what actually proves no compilation happened. File.Exists(Path.Combine(testApp, "bin", "Debug", TestPaths.TestAppTargetFramework, $"{GitPropertiesTestWorkspace.TestAppProjectName}.dll")).Should() .BeFalse("this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); } +} +public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest : GitPropertiesBuildTestBase +{ /// - /// End-to-end simulation of the actual documented workflow - the lightweight-target equivalent of + /// End-to-end simulation of the actual documented workflow - the lightweight-target equivalent of /// (which uses a full build instead): produce the fallback file via /// /// WriteGitPropertiesFallbackFile @@ -639,48 +716,54 @@ public void WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling /// , simulate a source-based `cf push`, and confirm the server-side publish still picks it up correctly. /// [Fact] - public void WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() + public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 2, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult writeResult = ProcessRunner.RunDotnet(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); - Dictionary fallbackProperties = PropertiesFile.Read(FallbackFile(testApp)); + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(_workspace.RootDirectory, "pushed")); + string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); - File.Exists(FallbackFile(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - ProcessResult publishResult = ProcessRunner.RunDotnet(pushedApp, "publish", "-v:detailed"); + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); - publishResult.Output.Should() - .Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + publishResult.Output.Should().Contain( + "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - Dictionary publishedProperties = PropertiesFile.Read(ReleasePublishGitPropertiesFile(pushedApp)); + Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); } +} +public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPropertiesBuildTestBase +{ /// /// "--no-restore" must work the same way for this target as for any other build invocation - it only requires that restore already happened at least /// once, same as a normal build. /// [Fact] - public void WriteGitPropertiesFallbackFile_WorksWithNoRestore() + public async Task WriteGitPropertiesFallbackFile_WorksWithNoRestore() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult restoreResult = ProcessRunner.RunDotnet(testApp, "restore"); + ProcessResult restoreResult = await ProcessRunner.RunDotnetAsync(testApp, "restore"); AssertBuildSucceeded(restoreResult, "restore"); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); AssertBuildSucceeded(result, "build --no-restore -t:WriteGitPropertiesFallbackFile"); - File.Exists(FallbackFile(testApp)).Should().BeTrue(); + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue(); } +} +public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : GitPropertiesBuildTestBase +{ /// /// Documents/guards the one real caveat called out in PackageReadme.md: this target never produces real build output, so a local "dotnet publish /// --no-build" afterward must fail - there is nothing compiled to publish. If this target's own implementation ever accidentally started producing @@ -688,20 +771,23 @@ public void WriteGitPropertiesFallbackFile_WorksWithNoRestore() /// succeeding) - a signal to revisit the target, not just delete this test. /// [Fact] - public void WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() + public async Task WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1, true); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult writeResult = ProcessRunner.RunDotnet(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); - ProcessResult publishResult = ProcessRunner.RunDotnet(testApp, "publish", "--no-build"); + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "--no-build"); publishResult.ExitCode.Should().NotBe(0, "publishing --no-build after only writing the fallback file (no real build ever ran) must fail - there is no compiled output to publish."); } +} +public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest : GitPropertiesBuildTestBase +{ /// /// Every other test here consumes Steeltoe.Management.GitProperties.Build straight from source (ProjectReference + Import) - this is the only one that /// goes through a real, packed .nupkg via <PackageReference>, the way an actual external user of the package would. That exercises the NuGet @@ -712,25 +798,27 @@ public void WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() /// stale result from - the machine-wide global-packages cache at %userprofile%\.nuget\packages. /// [Fact] - public void NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() + public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string feedDirectory = _workspace.PackGitPropertiesBuildToFeed(); + string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); + string packageId = await TestPaths.GetPackageIdAsync(); - string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{TestPaths.PackageId}.*.nupkg"); + string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); nuPkgFiles.Should().ContainSingle("packing should produce exactly one .nupkg."); - Match versionMatch = NuPkgVersionRegex.Match(Path.GetFileName(nuPkgFiles[0])); + 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("the .nupkg file name should embed the package version."); string packageVersion = versionMatch.Groups[1].Value; string consumerDirectory = Path.Combine(repository, "Consumer"); - GitPropertiesTestWorkspace.CreatePackageConsumerProject(consumerDirectory, packageVersion); - GitPropertiesTestWorkspace.WriteIsolatedNuGetConfig(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); + await GitPropertiesTestWorkspace.CreatePackageConsumerProjectAsync(consumerDirectory, packageVersion); + await GitPropertiesTestWorkspace.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); - string isolatedPackagesPath = Path.Combine(_workspace.RootDirectory, "isolated-packages"); - ProcessResult result = ProcessRunner.RunDotnet(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); + string isolatedPackagesPath = Path.Combine(Workspace.RootDirectory, "isolated-packages"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); AssertBuildSucceeded(result, "the build of a project consuming Steeltoe.Management.GitProperties.Build via PackageReference"); result.Output.Should().Contain("0 Warning(s)", @@ -740,99 +828,120 @@ public void NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() // an arbitrary case normalization, so ToUpperInvariant() (as generally preferred) would look here for // a folder that NuGet never creates. #pragma warning disable S4040 - string lowerCasePackageId = TestPaths.PackageId.ToLowerInvariant(); + string lowerCasePackageId = packageId.ToLowerInvariant(); #pragma warning restore S4040 - Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should() - .BeTrue("the package should restore into the isolated path, never the machine-wide global-packages cache."); + Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should().BeTrue( + "the package should restore into the isolated path, never the machine-wide global-packages cache."); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(consumerDirectory)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(consumerDirectory)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); } +} +public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest : GitPropertiesBuildTestBase +{ /// /// The default case for the overwhelming majority of projects in a large solution (a class library, a test project, anything without a consuming package /// anywhere in its resolved dependency graph): generation is skipped entirely, without needing an explicit opt-out, and without breaking the build. A - /// real git repository is deliberately present here (unlike NoGit_WarnsByDefault) to prove the smart default - not "no .git found" - is what causes the - /// skip. + /// real git repository is deliberately present here (unlike NoGitWarnsByDefaultTest) to prove the smart default - not "no .git found" - is what causes + /// the skip. /// [Fact] - public void SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() + public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + + string testApp = + await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-v:detailed"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); AssertBuildSucceeded(result, "build with no consuming-package reference and $(GenerateGitProperties) left at its smart default"); // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). result.Output.Should().Contain("git.properties generation skipped: no reference to"); AssertNoGitPropertiesGenerated(testApp); } +} +public sealed class SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest : GitPropertiesBuildTestBase +{ /// - /// The positive counterpart to : a project referencing the real default + /// The positive counterpart to : a project referencing the real default /// consuming package ID (Steeltoe.Management.Endpoint) gets git.properties generated with no explicit $(GenerateGitProperties) needed. Uses a minimal - /// stand-in project with that exact name/PackageId (see WriteDummyDependencyProject's remarks) rather than the real, large Endpoint project, so this - /// test stays fast and fully offline. + /// stand-in project with that exact name/PackageId (see WriteDummyDependencyProjectAsync's remarks) rather than the real, large Endpoint project, so + /// this test stays fast and fully offline. /// [Fact] - public void SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() + public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, consumingPackageStandInName); + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertBuildSucceeded(result, "build with a Steeltoe.Management.Endpoint reference and $(GenerateGitProperties) left at its smart default"); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); } +} +public sealed class SmartDefaultOverrideDetectsCustomPackageIdsTest : GitPropertiesBuildTestBase +{ /// /// Proves $(GitPropertiesConsumingPackageIds) is genuinely overridable - for consumers of this package who don't use Steeltoe.Management.Endpoint at all /// (e.g. a hand-rolled /info endpoint reading git.properties directly), so the smart default isn't hardcoded away from them. /// [Fact] - public void SmartDefault_Override_DetectsCustomPackageIds() + public async Task SmartDefault_Override_DetectsCustomPackageIds() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string customPackageId = "Contoso.Actuators"; - GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, customPackageId); + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, customPackageId); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); AssertBuildSucceeded(result, "build with a custom $(GitPropertiesConsumingPackageIds) matching a referenced project"); - Dictionary properties = PropertiesFile.Read(DebugGitPropertiesFile(testApp)); - properties["git.commit.id"].Should().Be(ProcessRunner.GetGitOutput(repository, "rev-parse", "HEAD")); + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); } +} +public sealed class SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest : GitPropertiesBuildTestBase +{ /// /// Guards against a regression to a naive substring match (e.g. "IndexOf(id + "/")" without also requiring the match to be a whole library key) - a /// project referencing only "Some2" (never "Some" itself) must NOT be detected when $(GitPropertiesConsumingPackageIds) is configured as "Some", even /// though "Some2" starts with "Some". Proves DetectConsumingPackageReferenceTask compares whole package IDs, not prefixes. /// [Fact] - public void SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() + public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string longerPackageId = "Some2"; - GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, longerPackageId); + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, longerPackageId); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); AssertBuildSucceeded(result, "build with a referenced package ('Some2') that is a superstring, not a match, of the configured ID ('Some')"); AssertNoGitPropertiesGenerated(testApp); } +} +public sealed class SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest : GitPropertiesBuildTestBase +{ /// /// Guards against a regression where MSBuild's required-parameter check for a Task string parameter treats an empty string the same as "not supplied": /// setting $(GitPropertiesConsumingPackageIds) to blank via a global property (e.g. "-p:GitPropertiesConsumingPackageIds=") reaches @@ -841,20 +950,24 @@ public void SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() /// i.e. skip generation gracefully rather than fail the build with MSB4044. /// [Fact] - public void SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() + public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, consumingPackageStandInName); + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); AssertBuildSucceeded(result, "build with $(GitPropertiesConsumingPackageIds) explicitly cleared via a global property"); AssertNoGitPropertiesGenerated(testApp); } +} +public sealed class SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest : GitPropertiesBuildTestBase +{ /// /// A consumer's explicit choice must never be second-guessed by the smart default, in either direction - the negative direction (no reference, but /// explicitly forced on) is already exercised by every other test in this file, which all set $(GenerateGitProperties)=true explicitly via @@ -862,68 +975,18 @@ public void SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerati /// the consumer explicitly opted out anyway. /// [Fact] - public void SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() + public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() { - string repository = _workspace.CreateSyntheticRepo(Path.Combine(_workspace.RootDirectory, "repo"), 1); + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - GitPropertiesTestWorkspace.WriteDummyDependencyProject(repository, consumingPackageStandInName); + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - string testApp = GitPropertiesTestWorkspace.WriteAppProject(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = ProcessRunner.RunDotnet(testApp, "build", "-p:GenerateGitProperties=false"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); AssertBuildSucceeded(result, "build with GenerateGitProperties explicitly set to false despite a consuming-package reference being present"); AssertNoGitPropertiesGenerated(testApp); } - - private static Dictionary[] ReadPropertiesForEachFramework(string projectDirectory, string[] frameworks) - { - return Array.ConvertAll(frameworks, framework => PropertiesFile.Read(Path.Combine(projectDirectory, "bin", "Debug", framework, "git.properties"))); - } - - private static string FallbackFile(string projectDirectory) - { - return Path.Combine(projectDirectory, "git.properties"); - } - - public void Dispose() - { - _workspace.Dispose(); - } - - private static void AssertBuildSucceeded(ProcessResult result, string action) - { - result.ExitCode.Should().Be(0, "{0} should succeed. Output:\n{1}", action, result.Output); - } - - private static void AssertWarned(ProcessResult result, string code) - { - result.Output.Should().Contain($"warning {code}"); - } - - /// - /// GitPropertiesEnableWarnings=false downgrades a diagnostic from a Warning to a plain informational message - with no code at all (see - /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why), and at Importance="Normal" rather than the default's "high", so it's visible at - /// "-v:normal" but not in default build output. - /// - private static void AssertReportedAsInfoOnly(ProcessResult result, string code, string messageSnippet) - { - result.Output.Should().NotContain(code, "a downgraded message must never carry a code - only warnings do."); - result.Output.Should().Contain(messageSnippet); - } - - private static void AssertNoGitPropertiesGenerated(string projectDirectory) - { - File.Exists(DebugGitPropertiesFile(projectDirectory)).Should().BeFalse("no git.properties should be generated."); - } - - private static string DebugGitPropertiesFile(string projectDirectory) - { - return Path.Combine(projectDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - } - - private static string ReleasePublishGitPropertiesFile(string projectDirectory) - { - return Path.Combine(projectDirectory, "bin", "Release", TestPaths.TestAppTargetFramework, "publish", "git.properties"); - } } diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index aa90b53dc0..69ec015216 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -11,11 +11,15 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// test's workspace path can never accidentally satisfy an Assert.Contains/DoesNotContain check against a GITPROPS0xx diagnostic code in build output, /// which routinely echoes back the working directory path. /// +/// +/// Constructed via rather than a public constructor: resolving the physical (symlink-free) root directory on macOS needs a +/// "pwd -P" subprocess, and a constructor can't await one. +/// internal sealed class GitPropertiesTestWorkspace : IDisposable { /// /// The project name every dev-loop consumer test writes its own copy of Steeltoe.Management.GitProperties.Build against (see - /// ) - shared so callers never retype it. + /// ) - shared so callers never retype it. /// public const string TestAppProjectName = "TestApp"; @@ -28,27 +32,33 @@ internal sealed class GitPropertiesTestWorkspace : IDisposable public string RootDirectory { get; } - public GitPropertiesTestWorkspace() + 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); - RootDirectory = ResolvePhysicalPath(rootDirectory); + string physicalRootDirectory = await ResolvePhysicalPathAsync(rootDirectory); + return new GitPropertiesTestWorkspace(physicalRootDirectory); } /// /// On macOS, $TMPDIR resolves through a symlink (/var -> /private/var) that the OS silently canonicalizes away whenever a spawned process (git, /// dotnet, MSBuild) reports its own working directory - e.g. in "git.properties: writing..." diagnostic messages. Resolving once up front here keeps - /// every path-based assertion in these tests (which compares against exactly that reported text) consistent with what a spawned process itself - /// reports, instead of the un-resolved alias $TMPDIR itself returns. + /// every path-based assertion in these tests (which compares against exactly that reported text) consistent with what a spawned process itself reports, + /// instead of the un-resolved alias $TMPDIR itself returns. /// - private static string ResolvePhysicalPath(string path) + private static async Task ResolvePhysicalPathAsync(string path) { if (!OperatingSystem.IsMacOS()) { return path; } - ProcessResult result = ProcessRunner.Run("pwd", path, "-P"); + ProcessResult result = await ProcessRunner.RunAsync("pwd", path, TestContext.Current.CancellationToken, "-P"); return result.Output.Trim(); } @@ -96,15 +106,21 @@ private static void ClearReadOnlyAttributes(DirectoryInfo directory) /// unhelpfully for these tests) detect "packaged" consumption instead of the dev loop this is meant to simulate, silently skipping the TaskHostFactory /// path every other test here relies on. /// - private static void CopyGitPropertiesBuildSource(string repoRootDestination) + private static async Task CopyGitPropertiesBuildSourceAsync(string repoRootDestination) { string destination = Path.Combine(repoRootDestination, TestPaths.GitPropertiesBuildRelativePath); Directory.CreateDirectory(Path.Combine(destination, "build")); - File.Copy(TestPaths.GitPropertiesBuildProjectFile, Path.Combine(destination, Path.GetFileName(TestPaths.GitPropertiesBuildProjectFile)), true); - File.Copy(TestPaths.TargetsFile, Path.Combine(destination, "build", Path.GetFileName(TestPaths.TargetsFile)), true); - File.Copy(TestPaths.SourceCheckoutMarkerFile, Path.Combine(destination, Path.GetFileName(TestPaths.SourceCheckoutMarkerFile)), true); - foreach (string sourceFile in Directory.GetFiles(TestPaths.GitPropertiesBuildDirectory, "*.cs")) + string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); + string targetsFile = await TestPaths.GetTargetsFileAsync(); + string markerFile = await TestPaths.GetSourceCheckoutMarkerFileAsync(); + string buildDirectory = await TestPaths.GetGitPropertiesBuildDirectoryAsync(); + + File.Copy(projectFile, Path.Combine(destination, Path.GetFileName(projectFile)), true); + File.Copy(targetsFile, Path.Combine(destination, "build", Path.GetFileName(targetsFile)), true); + File.Copy(markerFile, Path.Combine(destination, Path.GetFileName(markerFile)), true); + + foreach (string sourceFile in Directory.GetFiles(buildDirectory, "*.cs")) { File.Copy(sourceFile, Path.Combine(destination, Path.GetFileName(sourceFile)), true); } @@ -115,13 +131,14 @@ private static void CopyGitPropertiesBuildSource(string repoRootDestination) /// chain needs (versioning, packaging defaults, analyzers), so a synthetic test repo - which has none of Steeltoe's other real projects - still /// evaluates the same properties a real build inside the repository would. /// - private static void CopySharedBuildInfrastructure(string repoRootDestination) + private static async Task CopySharedBuildInfrastructureAsync(string repoRootDestination) { Directory.CreateDirectory(repoRootDestination); + string repositoryRoot = await TestPaths.GetRepositoryRootAsync(); foreach (string fileName in TestPaths.SharedBuildInfrastructureFiles) { - File.Copy(Path.Combine(TestPaths.RepositoryRoot, fileName), Path.Combine(repoRootDestination, fileName), true); + File.Copy(Path.Combine(repositoryRoot, fileName), Path.Combine(repoRootDestination, fileName), true); } } @@ -134,7 +151,7 @@ private static void CopySharedBuildInfrastructure(string repoRootDestination) /// semicolon-separated list instead (see ). /// /// - /// The directory to write the project under - typically a repository root returned by . + /// The directory to write the project under - typically a repository root returned by . /// /// /// The project's name - also used as its directory and file name. @@ -146,15 +163,15 @@ private static void CopySharedBuildInfrastructure(string repoRootDestination) /// /// Emits an explicit $(GenerateGitProperties) override when non-null - true for almost every test here, since they exist to test generation itself, not /// the smart default that decides whether it runs at all. Pass null (letting the smart default apply) only for tests that specifically cover - /// DetectConsumingPackageReferenceTask/$(GitPropertiesConsumingPackageIds) - see . + /// DetectConsumingPackageReferenceTask/$(GitPropertiesConsumingPackageIds) - see . /// /// /// Extra raw XML inserted into the same <ItemGroup> as the ProjectReference above - currently only used to add a second, normal (not - /// ReferenceOutputAssembly="false") ProjectReference to a stand-in, so it actually participates in restore - /// and shows up in this project's own project.assets.json (see DetectConsumingPackageReferenceTask's remarks for why that distinction matters). + /// ReferenceOutputAssembly="false") ProjectReference to a stand-in, so it actually participates in + /// restore and shows up in this project's own project.assets.json (see DetectConsumingPackageReferenceTask's remarks for why that distinction matters). /// - public static string WriteAppProject(string repoRootDestination, string projectName, string? targetFrameworks = null, bool? generateGitProperties = true, - string? extraItemGroupContent = null) + public static async Task WriteAppProjectAsync(string repoRootDestination, string projectName, string? targetFrameworks = null, + bool? generateGitProperties = true, string? extraItemGroupContent = null) { string appDirectory = Path.Combine(repoRootDestination, projectName); Directory.CreateDirectory(appDirectory); @@ -171,6 +188,9 @@ public static string WriteAppProject(string repoRootDestination, string projectN generateGitPropertiesElement = $"{generateGitPropertiesValue}"; } + string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); + string targetsFile = await TestPaths.GetTargetsFileAsync(); + string projectContent = $""" @@ -182,21 +202,21 @@ public static string WriteAppProject(string repoRootDestination, string projectN - + false {extraItemGroupContent} - + """; - File.WriteAllText(Path.Combine(appDirectory, $"{projectName}.csproj"), projectContent); + await File.WriteAllTextAsync(Path.Combine(appDirectory, $"{projectName}.csproj"), projectContent, TestContext.Current.CancellationToken); - File.WriteAllText(Path.Combine(appDirectory, "Program.cs"), """ + await File.WriteAllTextAsync(Path.Combine(appDirectory, "Program.cs"), """ Console.WriteLine("Hello, World!"); - """); + """, TestContext.Current.CancellationToken); return appDirectory; } @@ -205,21 +225,21 @@ public static string WriteAppProject(string repoRootDestination, string projectN /// Writes a minimal, do-nothing class library project - used only by the smart-default detection tests as a stand-in for a real dependency (e.g. /// Steeltoe.Management.Endpoint itself, or some other actuator-registering package) that a test app references NORMALLY (see /// 's caller), so it actually participates in restore and shows up in the referencing project's own - /// project.assets.json - unlike WriteAppProject's own ReferenceOutputAssembly="false" reference to Steeltoe.Management.GitProperties.Build, which was - /// verified (empirically, against a real "dotnet restore") to be excluded from the resolved dependency graph entirely. + /// project.assets.json - unlike WriteAppProjectAsync's own ReferenceOutputAssembly="false" reference to Steeltoe.Management.GitProperties.Build, which + /// was verified (empirically, against a real "dotnet restore") to be excluded from the resolved dependency graph entirely. /// - public static string WriteDummyDependencyProject(string repoRootDestination, string projectName) + public static async Task WriteDummyDependencyProjectAsync(string repoRootDestination, string projectName) { string projectDirectory = Path.Combine(repoRootDestination, projectName); Directory.CreateDirectory(projectDirectory); - File.WriteAllText(Path.Combine(projectDirectory, $"{projectName}.csproj"), $""" + await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), $""" {TestPaths.TestAppTargetFramework} - """); + """, TestContext.Current.CancellationToken); return projectDirectory; } @@ -242,12 +262,12 @@ public static string WriteDummyDependencyProject(string repoRootDestination, str /// specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that accidentally wrote one in a test that doesn't /// expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being silently absorbed by a blanket ignore rule. /// - public string CreateSyntheticRepo(string destination, int commitCount, bool gitignoreFallbackFile = false) + public async Task CreateSyntheticRepoAsync(string destination, int commitCount, bool gitignoreFallbackFile = false) { Directory.CreateDirectory(destination); - ProcessRunner.RunGit(destination, "init", "--quiet", "--initial-branch=main", "."); - ProcessRunner.RunGit(destination, "config", "user.name", "Test User"); - ProcessRunner.RunGit(destination, "config", "user.email", "test@example.com"); + await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); + await ProcessRunner.RunGitAsync(destination, "config", "user.name", "Test User"); + await ProcessRunner.RunGitAsync(destination, "config", "user.email", "test@example.com"); // Without this, dotnet build's own obj/bin output is untracked and git status correctly // (but unhelpfully, for these tests) reports the tree as dirty - real projects always @@ -263,22 +283,24 @@ public string CreateSyntheticRepo(string destination, int commitCount, bool giti obj/ """; - File.WriteAllText(Path.Combine(destination, ".gitignore"), gitignoreContent); + await File.WriteAllTextAsync(Path.Combine(destination, ".gitignore"), gitignoreContent, TestContext.Current.CancellationToken); for (int commitNumber = 1; commitNumber <= commitCount; commitNumber++) { - File.WriteAllText(Path.Combine(destination, $"file{commitNumber}.txt"), $"content {commitNumber}"); - ProcessRunner.RunGit(destination, "add", "-A"); - ProcessRunner.RunGit(destination, "commit", "--quiet", "-m", $"Commit {commitNumber}"); + await File.WriteAllTextAsync(Path.Combine(destination, $"file{commitNumber}.txt"), $"content {commitNumber}", + TestContext.Current.CancellationToken); + + await ProcessRunner.RunGitAsync(destination, "add", "-A"); + await ProcessRunner.RunGitAsync(destination, "commit", "--quiet", "-m", $"Commit {commitNumber}"); } - CopyCurrentProjectFiles(destination); + await CopyCurrentProjectFilesAsync(destination); // Commit the project files too, so the synthetic repo starts clean (git.dirty=false) // unless a test deliberately makes a further change - otherwise every synthetic repo would // show git.dirty=true purely because of these untracked-but-just-added files. - ProcessRunner.RunGit(destination, "add", "-A"); - ProcessRunner.RunGit(destination, "commit", "--quiet", "-m", "Add project files"); + await ProcessRunner.RunGitAsync(destination, "add", "-A"); + await ProcessRunner.RunGitAsync(destination, "commit", "--quiet", "-m", "Add project files"); return destination; } @@ -322,11 +344,11 @@ private static void CopyDirectoryExcluding(DirectoryInfo source, string destinat /// becomes the "repository root" for relative-path purposes, whether or not it's actually a git repository), then writes a TestApp project referencing /// it. Returns the TestApp directory. /// - public string CopyCurrentProjectFiles(string destination) + public async Task CopyCurrentProjectFilesAsync(string destination) { - CopySharedBuildInfrastructure(destination); - CopyGitPropertiesBuildSource(destination); - return WriteAppProject(destination, TestAppProjectName); + await CopySharedBuildInfrastructureAsync(destination); + await CopyGitPropertiesBuildSourceAsync(destination); + return await WriteAppProjectAsync(destination, TestAppProjectName); } /// @@ -337,21 +359,24 @@ public string CopyCurrentProjectFiles(string destination) /// (IncludeBuildOutput=false makes NuGet's Pack target skip its usual dependency on Build), so it fails with NU5019 ("file not found") against the DLL /// our own <None Include="$(TargetPath)"> pack item expects to already exist. /// - public string PackGitPropertiesBuildToFeed() + public async Task PackGitPropertiesBuildToFeedAsync() { string packSourceDirectory = Path.Combine(RootDirectory, "pack-source"); - CopySharedBuildInfrastructure(packSourceDirectory); - CopyGitPropertiesBuildSource(packSourceDirectory); + await CopySharedBuildInfrastructureAsync(packSourceDirectory); + await CopyGitPropertiesBuildSourceAsync(packSourceDirectory); string projectDirectory = Path.Combine(packSourceDirectory, TestPaths.GitPropertiesBuildRelativePath); - ProcessResult result = ProcessRunner.RunDotnet(projectDirectory, "build", Path.GetFileName(TestPaths.GitPropertiesBuildProjectFile), "-c", "Release"); + string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); + ProcessResult result = await ProcessRunner.RunDotnetAsync(projectDirectory, "build", Path.GetFileName(projectFile), "-c", "Release"); if (result.ExitCode != 0) { - throw new InvalidOperationException($"Building/packing {TestPaths.PackageId} failed.\n{result.Output}"); + string packageId = await TestPaths.GetPackageIdAsync(); + throw new InvalidOperationException($"Building/packing {packageId} failed.\n{result.Output}"); } - return Path.Combine(projectDirectory, "bin", "tasks", TestPaths.GitPropertiesBuildTargetFramework); + string targetFramework = await TestPaths.GetGitPropertiesBuildTargetFrameworkAsync(); + return Path.Combine(projectDirectory, "bin", "tasks", targetFramework); } /// @@ -359,7 +384,7 @@ public string PackGitPropertiesBuildToFeed() /// clear/>, restore would still see nuget.org and friends - harmless here since nothing else is needed, but clearing makes the test fully /// offline-capable and guarantees it's really our local build being consumed, not a same-named/versioned package resolved from somewhere else. /// - public static void WriteIsolatedNuGetConfig(string filePath, string feedDirectory) + public static async Task WriteIsolatedNuGetConfigAsync(string filePath, string feedDirectory) { string content = $""" @@ -371,7 +396,7 @@ public static void WriteIsolatedNuGetConfig(string filePath, string feedDirector """; - File.WriteAllText(filePath, content); + await File.WriteAllTextAsync(filePath, content, TestContext.Current.CancellationToken); } /// @@ -380,7 +405,7 @@ public static void WriteIsolatedNuGetConfig(string filePath, string feedDirector /// .targets file: that's the whole point of the "build\{PackageId}.targets" NuGet auto-import convention this package relies on, and this is the only /// test that actually exercises it end-to-end. /// - public static void CreatePackageConsumerProject(string projectDirectory, string packageVersion) + public static async Task CreatePackageConsumerProjectAsync(string projectDirectory, string packageVersion) { Directory.CreateDirectory(projectDirectory); @@ -391,7 +416,7 @@ public static void CreatePackageConsumerProject(string projectDirectory, string {TestPaths.TestAppTargetFramework} enable true @@ -402,10 +427,10 @@ public static void CreatePackageConsumerProject(string projectDirectory, string """; - File.WriteAllText(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent); + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent, TestContext.Current.CancellationToken); - File.WriteAllText(Path.Combine(projectDirectory, "Program.cs"), """ + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), """ Console.WriteLine("Hello, World!"); - """); + """, TestContext.Current.CancellationToken); } } diff --git a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs index 0cfae78432..cadb25678f 100644 --- a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -21,9 +21,21 @@ internal static class ProcessRunner '\n' ]; - public static readonly string RealGitExecutable = ResolveGitExecutable(); - - public static ProcessResult Run(string fileName, string workingDirectory, params string[] arguments) + /// + /// Generous enough to comfortably cover the slowest single command this suite ever runs (a Release build plus NuGet pack, or a "dotnet build" against a + /// cold/isolated restore) even under heavy system load, while still turning a genuine hang - e.g. a lingering process holding the redirected output pipe + /// open, the exact failure mode 's own MSBUILDDISABLENODEREUSE setting guards against - into a fast, informative test failure + /// instead of blocking the whole suite indefinitely. + /// + private static readonly TimeSpan ProcessExitTimeout = TimeSpan.FromMinutes(2); + + /// + /// Started once, eagerly, the moment this class is first touched - every caller (RunGitAsync and, transitively, everything else here) awaits this same + /// Task instead of re-resolving "where git" on every single call, or blocking a thread synchronously on it. + /// + private static readonly Task RealGitExecutableTask = ResolveGitExecutableAsync(); + + public static async Task RunAsync(string fileName, string workingDirectory, CancellationToken cancellationToken, params string[] arguments) { var outputBuilder = new StringBuilder(); object outputLock = new(); @@ -45,6 +57,15 @@ public static ProcessResult Run(string fileName, string workingDirectory, params startInfo.ArgumentList.Add(argument); } + // Without this, a spawned "dotnet build"/"publish" leaves a persistent MSBuild worker node + // running in the background for reuse by a later build (the SDK's default, off a dev + // machine with no CI environment variable set). That node inherits our redirected + // stdout/stderr pipe handles and keeps them open even after the process we launched here + // exits - so the read end never sees EOF, and awaiting exit below would otherwise block + // forever waiting for a pipe close that will never happen, even though the build already + // completed successfully. + startInfo.EnvironmentVariables["MSBUILDDISABLENODEREUSE"] = "1"; + using var process = new Process(); process.StartInfo = startInfo; process.OutputDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data); @@ -52,7 +73,35 @@ public static ProcessResult Run(string fileName, string workingDirectory, params process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); - process.WaitForExit(); + + // Linked, not just our own timeout: composes the caller's own cancellation (e.g. xUnit + // cancelling the test run via TestContext.Current.CancellationToken) with our internal + // "this must be a hung pipe" timeout, without one silently overriding the other. + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(ProcessExitTimeout); + + try + { + // WaitForExitAsync, unlike the synchronous WaitForExit, never ties up a thread pool + // thread for the (up to) two minutes this can wait - it composes with the timeout purely + // through cancellation instead. + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + KillEntireProcessTreeInBackground(process.Id); + + if (cancellationToken.IsCancellationRequested) + { + // The caller's own token fired, not our internal timeout - propagate that as-is + // rather than obscuring a genuine cancellation behind a misleading TimeoutException. + throw; + } + + throw new TimeoutException( + $"'{fileName} {string.Join(' ', arguments)}' in '{workingDirectory}' did not exit within {ProcessExitTimeout} - probably a hung " + + "process holding the redirected output pipe open rather than the command itself still genuinely running."); + } string output = outputBuilder.ToString(); return new ProcessResult(process.ExitCode, output); @@ -80,25 +129,86 @@ void AppendLine(string? line) } } - public static ProcessResult RunGit(string workingDirectory, params string[] arguments) + /// + /// Fire-and-forget, not awaited: with + /// + /// entireProcessTree: true + /// + /// walks every process on the machine to find descendants by parent-PID/start-time matching, which measurably takes several seconds on a machine with a + /// typical number of processes running - blocking the cancellation/timeout path on that would make a cancelled test appear to hang for that same several + /// seconds even though the test itself already stopped waiting. Re-resolves the process by id, rather than closing over the original (disposed by + /// 's own "using") instance, since calling a method on an already-disposed instance from this background + /// task would throw. Best-effort only: a failure here (the process already exited, or its id got reused by an unrelated process) must never surface + /// anywhere, since nothing awaits this. + /// + private static void KillEntireProcessTreeInBackground(int processId) + { + _ = Task.Run(() => + { + try + { + using var process = Process.GetProcessById(processId); + process.Kill(true); + } + catch (Exception) + { + // Intentionally left empty. + } + }); + } + + /// + /// Uses the currently-running test's own TestContext.Current.CancellationToken - the right default for every call site in this suite except one: see the + /// explicit-CancellationToken overload below for why TestPaths.ResolveRepositoryRootAsync can't use this one. + /// + public static Task RunGitAsync(string workingDirectory, params string[] arguments) + { + return RunGitAsync(workingDirectory, TestContext.Current.CancellationToken, arguments); + } + + /// + /// Explicit-CancellationToken overload, used only by TestPaths.ResolveRepositoryRootAsync's shared, fire-once resolution with CancellationToken.None - + /// see that method's own remarks (and 's, for the identical reasoning) for why a specific test's + /// TestContext.Current.CancellationToken would be wrong there. + /// + public static async Task RunGitAsync(string workingDirectory, CancellationToken cancellationToken, params string[] arguments) { - return Run(RealGitExecutable, workingDirectory, arguments); + string gitExecutable = await RealGitExecutableTask; + return await RunAsync(gitExecutable, workingDirectory, cancellationToken, arguments); } - public static ProcessResult RunDotnet(string workingDirectory, params string[] arguments) + /// + /// Always appends "-p:RunAnalyzers=false" and "-p:NuGetAudit=false" - measured to save only ~0.4-0.5s on a standalone build of the (tiny) task assembly, + /// and within run-to-run noise once folded into a real end-to-end TestApp build, but harmless either way here: no test in this suite asserts on analyzer + /// diagnostics or NuGet audit warnings, only on this project's own GITPROPS0xx codes and plain build success/failure. + /// + public static Task RunDotnetAsync(string workingDirectory, params string[] arguments) { - return Run("dotnet", workingDirectory, arguments); + string[] allArguments = + [ + .. arguments, + "-p:RunAnalyzers=false", + "-p:NuGetAudit=false" + ]; + + return RunAsync("dotnet", workingDirectory, TestContext.Current.CancellationToken, allArguments); } - public static string GetGitOutput(string workingDirectory, params string[] arguments) + public static async Task GetGitOutputAsync(string workingDirectory, params string[] arguments) { - ProcessResult result = RunGit(workingDirectory, arguments); + ProcessResult result = await RunGitAsync(workingDirectory, arguments); return result.Output.Trim(); } - private static string ResolveGitExecutable() + /// + /// Deliberately CancellationToken.None, not a specific test's TestContext.Current.CancellationToken: is a single, + /// process-wide resource shared by every test class running concurrently, resolved once by whichever test happens to touch this class first - tying that + /// one-time resolution to that particular (arbitrary, unrelated) test's cancellation would be wrong, since cancelling THAT test must not cancel + /// resolution for every OTHER test still waiting on the same shared Task. + /// + private static async Task ResolveGitExecutableAsync() { - ProcessResult result = Run(LocatorCommand, Path.GetTempPath(), "git"); + ProcessResult result = await RunAsync(LocatorCommand, Path.GetTempPath(), CancellationToken.None, "git"); string firstLine = result.Output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? throw new InvalidOperationException($"Could not resolve the location of git via '{LocatorCommand} git'."); diff --git a/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs b/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs index 72d25f44f8..ed9300e1d3 100644 --- a/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs +++ b/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs @@ -8,7 +8,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; internal static class PropertiesFile { - public static Dictionary Read(string path) + public static async Task> ReadAsync(string path) { if (!File.Exists(path)) { @@ -17,7 +17,7 @@ public static Dictionary Read(string path) var map = new Dictionary(); - foreach (string line in File.ReadAllLines(path, Encoding.UTF8)) + foreach (string line in await File.ReadAllLinesAsync(path, Encoding.UTF8, TestContext.Current.CancellationToken)) { if (!line.StartsWith("git.", StringComparison.Ordinal)) { diff --git a/src/Management/test/GitProperties.Build.Test/TestPaths.cs b/src/Management/test/GitProperties.Build.Test/TestPaths.cs index e80bb45aca..c3a14166ce 100644 --- a/src/Management/test/GitProperties.Build.Test/TestPaths.cs +++ b/src/Management/test/GitProperties.Build.Test/TestPaths.cs @@ -14,6 +14,11 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// on disk right now. Resolved once per test run via the test source file's own location, so these tests work regardless of where the test assembly's /// bin/ output lives. /// +/// +/// is the only member here that needs a process (git) to resolve, so it's the only piece that's genuinely async - it's +/// started once, eagerly, and every dependent path below just awaits that same completed Task and does cheap, synchronous string/regex work on top, so +/// none of this re-invokes git. +/// internal static partial class TestPaths { /// @@ -24,9 +29,9 @@ internal static partial class TestPaths public const string GitPropertiesBuildRelativePath = "src/Management/src/GitProperties.Build"; /// - /// The TargetFramework every generated test-app .csproj (TestApp, ProjA/ProjB, the PackageReference consumer) is written with - read from this test - /// assembly's own $(TargetFramework) via the AssemblyMetadata item in Steeltoe.Management.GitProperties.Build.Test.csproj, so bumping that one property - /// is the only change needed when a new TFM becomes current. + /// The TargetFramework every generated test-app .csproj (TestApp, ProjectA/ProjectB, the PackageReference consumer) is written with - read from this + /// test assembly's own $(TargetFramework) via the AssemblyMetadata item in Steeltoe.Management.GitProperties.Build.Test.csproj, so bumping that one + /// property is the only change needed when a new TFM becomes current. /// public static readonly string TestAppTargetFramework = ResolveTestAppTargetFramework(); @@ -38,24 +43,6 @@ internal static partial class TestPaths /// public static readonly string MultiTargetTestFrameworks = BuildMultiTargetTestFrameworks(); - public static readonly string RepositoryRoot = ResolveRepositoryRoot(); - - public static readonly string GitPropertiesBuildDirectory = Path.Combine(RepositoryRoot, "src", "Management", "src", "GitProperties.Build"); - public static readonly string GitPropertiesBuildProjectFile = Path.Combine(GitPropertiesBuildDirectory, "Steeltoe.Management.GitProperties.Build.csproj"); - public static readonly string TargetsFile = Path.Combine(GitPropertiesBuildDirectory, "build", "Steeltoe.Management.GitProperties.Build.targets"); - public static readonly string SourceCheckoutMarkerFile = Path.Combine(GitPropertiesBuildDirectory, "SourceCheckout.txt"); - - /// - /// The NuGet package/assembly ID, derived from the real .csproj's own file name rather than retyped, so a future rename only needs to happen once. - /// - public static readonly string PackageId = Path.GetFileNameWithoutExtension(GitPropertiesBuildProjectFile); - - /// - /// Steeltoe.Management.GitProperties.Build.csproj's own <TargetFramework> (netstandard2.0, as of this writing) - parsed from the real .csproj - /// rather than hardcoded, so a future retargeting can't silently desync this from the test that packs and consumes the compiled task assembly. - /// - public static readonly string GitPropertiesBuildTargetFramework = ResolveGitPropertiesBuildTargetFramework(); - /// /// Root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own "shared.props" import (and that file's own /// imports) needs to resolve. Copied verbatim into every synthetic test repo, at the repository root, so a project built from this source outside the @@ -74,14 +61,59 @@ internal static partial class TestPaths "Steeltoe.Release.ruleset" ]; - private static string ResolveGitPropertiesBuildTargetFramework() + private static readonly Task RepositoryRootTask = ResolveRepositoryRootAsync(); + + public static Task GetRepositoryRootAsync() + { + return RepositoryRootTask; + } + + public static async Task GetGitPropertiesBuildDirectoryAsync() { - string projectContent = File.ReadAllText(GitPropertiesBuildProjectFile); + string repositoryRoot = await RepositoryRootTask; + return Path.Combine(repositoryRoot, "src", "Management", "src", "GitProperties.Build"); + } + + public static async Task GetGitPropertiesBuildProjectFileAsync() + { + string directory = await GetGitPropertiesBuildDirectoryAsync(); + return Path.Combine(directory, "Steeltoe.Management.GitProperties.Build.csproj"); + } + + public static async Task GetTargetsFileAsync() + { + string directory = await GetGitPropertiesBuildDirectoryAsync(); + return Path.Combine(directory, "build", "Steeltoe.Management.GitProperties.Build.targets"); + } + + public static async Task GetSourceCheckoutMarkerFileAsync() + { + string directory = await GetGitPropertiesBuildDirectoryAsync(); + return Path.Combine(directory, "SourceCheckout.txt"); + } + + /// + /// The NuGet package/assembly ID, derived from the real .csproj's own file name rather than retyped, so a future rename only needs to happen once. + /// + public static async Task GetPackageIdAsync() + { + string projectFile = await GetGitPropertiesBuildProjectFileAsync(); + return Path.GetFileNameWithoutExtension(projectFile); + } + + /// + /// Steeltoe.Management.GitProperties.Build.csproj's own <TargetFramework> (netstandard2.0, as of this writing) - parsed from the real .csproj + /// rather than hardcoded, so a future retargeting can't silently desync this from the test that packs and consumes the compiled task assembly. + /// + public 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 {GitPropertiesBuildProjectFile}."); + throw new InvalidOperationException($"Could not find in {projectFile}."); } return match.Groups[1].Value; @@ -114,10 +146,15 @@ private static string ResolveTestAppTargetFramework() return attribute?.Value ?? throw new InvalidOperationException("Could not resolve this test assembly's own TargetFramework from its AssemblyMetadata."); } - private static string ResolveRepositoryRoot([CallerFilePath] string sourceFilePath = "") + /// + /// Deliberately CancellationToken.None, not a specific test's TestContext.Current.CancellationToken: is a single, + /// process-wide resource shared by every test class running concurrently, resolved once by whichever test happens to touch this class first - see + /// ProcessRunner.ResolveGitExecutableAsync's own remarks for the identical reasoning. + /// + private static async Task ResolveRepositoryRootAsync([CallerFilePath] string sourceFilePath = "") { string sourceDirectory = Path.GetDirectoryName(sourceFilePath) ?? throw new InvalidOperationException("Could not determine the test source directory."); - ProcessResult result = ProcessRunner.Run(ProcessRunner.RealGitExecutable, sourceDirectory, "rev-parse", "--show-toplevel"); + ProcessResult result = await ProcessRunner.RunGitAsync(sourceDirectory, CancellationToken.None, "rev-parse", "--show-toplevel"); if (result.ExitCode != 0) { From 040dab4f98bd49ce7d8e1e74d3807daffded1c9c Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:30:35 +0200 Subject: [PATCH 09/20] Extract tests into separate files --- ...ChangesAcrossBuildsUnlikeCommitTimeTest.cs | 43 + ...backFileIgnoredWhenLiveGitAvailableTest.cs | 30 + .../FallbackFileUsedWhenNoGitAvailableTest.cs | 41 + ...itignoreMakesLaterBuildsAppearDirtyTest.cs | 35 + .../GitFileWarnsByDefaultTest.cs | 33 + .../GitPropertiesBuildTests.cs | 992 ------------------ .../GroundTruthAllPropertiesMatchGitTest.cs | 116 ++ ...talBuildCacheSkipsButDirtyStaysLiveTest.cs | 31 + .../MultiProjectSharesCacheTest.cs | 45 + ...ctSharesCacheAcrossTargetFrameworksTest.cs | 49 + .../MultipleRemotesOnlyOriginUrlIsUsedTest.cs | 36 + .../NewTagInvalidatesCacheTest.cs | 44 + .../NoGitInfoWhenEnableWarningsFalseTest.cs | 21 + .../NoGitWarnsByDefaultTest.cs | 21 + .../NonAsciiCommitDataRendersCorrectlyTest.cs | 36 + ...kageReferenceGeneratesGitPropertiesTest.cs | 61 ++ .../PublishIncludesGitPropertiesTest.cs | 23 + ...PublishNoBuildIncludesGitPropertiesTest.cs | 26 + ...lowCloneInfoWhenEnableWarningsFalseTest.cs | 29 + ...ShallowCloneLeavesCommitCountsEmptyTest.cs | 36 + ...erDetectedConsumingPackageReferenceTest.cs | 30 + ...rtiesWhenConsumingPackageReferencedTest.cs | 33 + ...aultOverrideDetectsCustomPackageIdsTest.cs | 30 + ...errideDoesNotMatchPackageIdAsPrefixTest.cs | 28 + ...alPropertySkipsGenerationGracefullyTest.cs | 31 + ...tionWhenNoConsumingPackageReferenceTest.cs | 29 + ...roducesFallbackFileWithoutCompilingTest.cs | 32 + ...FallbackFileThenPublishNoBuildFailsTest.cs | 29 + ...henSimulatedPushServerPublishUsesItTest.cs | 40 + ...rtiesFallbackFileWorksWithNoRestoreTest.cs | 27 + ...DirectoryCreatesFallbackFileOnBuildTest.cs | 39 + ...rectoryCreatesFallbackFileOnPublishTest.cs | 39 + 32 files changed, 1143 insertions(+), 992 deletions(-) create mode 100644 src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs diff --git a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs new file mode 100644 index 0000000000..fbf91eda7e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs @@ -0,0 +1,43 @@ +// 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; + +public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitPropertiesBuildTestBase +{ + /// + /// Guards against git.build.time accidentally ending up in the shared, cross-project/cross-TFM cache (see GenerateGitPropertiesCacheTask) instead of + /// being recomputed by ComposeGitPropertiesTask on every build - same class of regression IncrementalBuildCacheSkipsButDirtyStaysLiveTest guards against + /// for git.dirty. A cached build time would go stale (reporting the FIRST build's time on every subsequent one), silently defeating the whole point of + /// the field: telling you when THIS build actually ran. + /// + [Fact] + public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result1, "first build"); + Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + // git.build.time is formatted "yyyy-MM-ddTHH:mm:sszzz" (ComposeGitPropertiesTask) - second + // resolution only, no fractional part - so two builds landing within the same wall-clock + // second would produce identical values no matter how this test is written. This delay is + // sized to that format's own precision, not incidental slack. + await Task.Delay(TimeSpan.FromMilliseconds(1100), TestContext.Current.CancellationToken); + + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result2, "second build"); + Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"], + "git.build.time must be recomputed on every build, not reused from the shared cache."); + + propertiesAfter["git.commit.time"].Should().Be(propertiesBefore["git.commit.time"], + "git.commit.time must stay tied to the (unchanged) commit, unlike git.build.time."); + + propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"], "nothing about the commit itself changed between the two builds."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs new file mode 100644 index 0000000000..332cb65f40 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.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; + +public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesBuildTestBase +{ + /// + /// Guards against a stale fallback file (left over from some earlier build) ever shadowing live generation - the fallback file must only ever be used as + /// a last resort, never preferred over a real, currently-usable .git repository. + /// + [Fact] + public async Task FallbackFile_Ignored_WhenLiveGitAvailable() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + await File.WriteAllLinesAsync(GetFallbackFilePath(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], + TestContext.Current.CancellationToken); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + AssertBuildSucceeded(result, "build with a stale fallback file present alongside a real .git repository"); + result.Output.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs new file mode 100644 index 0000000000..2357ab00a4 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.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. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +public sealed class FallbackFileUsedWhenNoGitAvailableTest : GitPropertiesBuildTestBase +{ + /// + /// End-to-end simulation of the scenario that motivated $(GitPropertiesWriteToProjectDirectory) in the first place: `cf push` using the + /// dotnet_core_buildpack directly from source, which strips ".git" from the pushed tree unconditionally (see SimulateSourcePush) - meaning live + /// generation can never run for that push, ever. A pre-generated fallback file (produced by an earlier LOCAL build, where .git was available) must ride + /// along in the pushed source tree and get picked up, ending up in the published output exactly as if it had been generated live. + /// + [Fact] + public async Task FallbackFile_UsedWhenNoGitAvailable() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult fallbackResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(fallbackResult, "the local build that produces the fallback file"); + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + fallbackProperties["git.dirty"].Should().Be("false", "the gitignored fallback file must not make its own producing build see the tree as dirty."); + + string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); + string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); + Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); + File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); + AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); + publishResult.Output.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); + + publishResult.Output.Should().Contain( + "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + + Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); + publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs new file mode 100644 index 0000000000..aac6102f2e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs @@ -0,0 +1,35 @@ +// 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; + +public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest : GitPropertiesBuildTestBase +{ + /// + /// The negative counterpart to - proves the README's ".gitignore this file" warning + /// is describing a real consequence, not a hypothetical one: deliberately uses a repository WITHOUT the fallback file gitignored, so the file the first + /// build writes is left behind as a genuine untracked change - permanently flipping git.dirty to "true" on every later build, even though nothing about + /// the actually-tracked source changed in between. + /// + [Fact] + public async Task FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result1, "first build, which writes the (not yet gitignored) fallback file"); + + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + gitStatus.Should().NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); + + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result2, "second build"); + + Dictionary properties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + properties2["git.dirty"].Should().Be("true", + "the ungitignored fallback file left over from the first build makes every later build see the tree as dirty."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs new file mode 100644 index 0000000000..96336441f4 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs @@ -0,0 +1,33 @@ +// 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; + +public sealed class GitFileWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task GitFile_WarnsByDefault() + { + string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); + Directory.CreateDirectory(projectDirectory); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); + // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk + // (which starts at TestApp, the project actually being built) to find it - i.e. at + // projectDirectory itself. + await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj", TestContext.Current.CancellationToken); + + ProcessResult defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(defaultResult, "the build with .git as a file (a worktree/submodule checkout - e.g. an AI agent - must never fail)"); + AssertWarned(defaultResult, "GITPROPS002"); + AssertNoGitPropertiesGenerated(testApp); + + ProcessResult enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertBuildSucceeded(enableWarningsFalseResult, "build"); + AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS002", "resolves to a git worktree or submodule"); + + ProcessResult featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); + AssertBuildSucceeded(featureOffResult, "build with GenerateGitProperties=false"); + featureOffResult.Output.Should().NotContain("GITPROPS002"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs deleted file mode 100644 index 8a511f2550..0000000000 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTests.cs +++ /dev/null @@ -1,992 +0,0 @@ -// 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.Test; - -// Automated regression tests for the Steeltoe.Management.GitProperties.Build project -// (build/Steeltoe.Management.GitProperties.Build.targets plus its compiled MSBuild tasks). Exercises the -// scenarios that were manually verified while building this feature: ground-truth property values, -// incremental cache behavior, publish (with and without a prior build), the warn/info/skip diagnostic -// paths, shallow clones, non-ASCII commit data, and cross-project cache sharing. Every test runs against -// an isolated temporary workspace containing the CURRENT source of Steeltoe.Management.GitProperties.Build -// (see GitPropertiesTestWorkspace), not a stale git-tracked copy, so it always exercises whatever is on -// disk right now. Every git repository a test operates against is a small, synthetic one created from -// scratch (`git init` plus a handful of manufactured commits) - never a clone of this (large, real) -// repository - so the suite stays fast. Nothing here touches this repository's own working tree. -// -// One class per test (see GitPropertiesBuildTestBase's own remarks for why) rather than many [Fact] -// methods on one shared class: xUnit v3 runs different test classes concurrently by default, but never -// parallelizes methods within the same class. Every test here is dominated by "dotnet build"/"publish" -// subprocess time that's mostly I/O/wait-bound, not CPU-bound - splitting this way lets the whole suite's -// wall-clock approach its single slowest test instead of the sum of all of them. -// -// Measured (TRX per-test timing, sequential run): every test costs roughly 3.7-4.2 seconds PER "dotnet -// build"/"publish" subprocess it spawns, almost regardless of git-repository complexity - even the two -// tests with no git repository at all still cost ~3.7s each, matching the single-build cases with a real -// repository. Git setup (git init, commits, tags, config) is comparatively free by contrast. When a new -// scenario needs only one extra assertion against a plain default build, prefer folding it into an -// existing test that already builds one (see GroundTruthAllPropertiesMatchGitTest's own remarks for an -// example) over adding a dedicated test that pays for another subprocess just to re-run the identical -// setup. -public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTestBase -{ - /// - /// Also folds in two other checks against this same build, rather than paying for a dedicated "dotnet build" subprocess (by far the dominant cost of any - /// test in this suite - see the class remarks) just to exercise a single extra assertion against an otherwise identical, plain default build: that the - /// fallback file is never written unless explicitly opted into (see WriteToProjectDirectoryCreatesFallbackFileOnBuildTest for the positive case, which - - /// unlike this one - genuinely needs its own build, since it passes a different property), and that writing git.properties is confirmed at default - /// verbosity. - /// - [Fact] - public async Task GroundTruth_AllPropertiesMatchGit() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 3); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build"); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeFalse( - "the fallback file must not be written into the project directory unless explicitly opted into."); - - string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - result.Output.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - - string expectedCommitIdAbbrev = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "--short=7", "HEAD"); - properties["git.commit.id.abbrev"].Should().Be(expectedCommitIdAbbrev); - - string expectedCommitUserName = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%an"); - properties["git.commit.user.name"].Should().Be(expectedCommitUserName); - - string expectedCommitUserEmail = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%ae"); - properties["git.commit.user.email"].Should().Be(expectedCommitUserEmail); - - string expectedCommitMessageShort = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%s"); - properties["git.commit.message.short"].Should().Be(expectedCommitMessageShort); - - string expectedTotalCommitCount = await ProcessRunner.GetGitOutputAsync(repository, "rev-list", "--count", "HEAD"); - properties["git.total.commit.count"].Should().Be(expectedTotalCommitCount); - - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - bool expectedDirty = gitStatus.Length > 0; - 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), "git.build.time must reflect roughly when this build actually ran."); - - 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); - } -} - -public sealed class IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task IncrementalBuild_CacheSkipsButDirtyStaysLive() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "first build"); - string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); - File.Exists(cacheFile).Should().BeTrue("the cache file should exist after first build."); - - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); - AssertBuildSucceeded(result2, "second build"); - - // "Skipping target" is itself the deterministic, sufficient proof that nothing rewrote the - // cache file on this second build - no last-write-time comparison (and the sleep it would - // otherwise need, to guarantee a detectably different timestamp) is needed on top of it. - result2.Output.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - properties.Should().ContainKey("git.dirty"); - } -} - -public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitPropertiesBuildTestBase -{ - /// - /// Guards against git.build.time accidentally ending up in the shared, cross-project/cross-TFM cache (see GenerateGitPropertiesCacheTask) instead of - /// being recomputed by ComposeGitPropertiesTask on every build - same class of regression IncrementalBuildCacheSkipsButDirtyStaysLiveTest guards against - /// for git.dirty. A cached build time would go stale (reporting the FIRST build's time on every subsequent one), silently defeating the whole point of - /// the field: telling you when THIS build actually ran. - /// - [Fact] - public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "first build"); - Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - // git.build.time is formatted "yyyy-MM-ddTHH:mm:sszzz" (ComposeGitPropertiesTask) - second - // resolution only, no fractional part - so two builds landing within the same wall-clock - // second would produce identical values no matter how this test is written. This delay is - // sized to that format's own precision, not incidental slack. - await Task.Delay(TimeSpan.FromMilliseconds(1100), TestContext.Current.CancellationToken); - - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result2, "second build"); - Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"], - "git.build.time must be recomputed on every build, not reused from the shared cache."); - - propertiesAfter["git.commit.time"].Should().Be(propertiesBefore["git.commit.time"], - "git.commit.time must stay tied to the (unchanged) commit, unlike git.build.time."); - - propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"], "nothing about the commit itself changed between the two builds."); - } -} - -public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase -{ - /// - /// Also folds in coverage for the trickiest shape ParseDescribeOutput's dash-splitting has to get right - a tag name that itself contains a dash - /// ("release-1.0"), combined with a nonzero commits-ahead count - rather than spinning up a dedicated test just for that. Tagging an ANCESTOR of HEAD, - /// not HEAD itself, serves both purposes at once: it produces that nonzero count, and it keeps HEAD's own commit ID unchanged, which is the actual point - /// of this test's name - proving a new tag ref alone still invalidates the shared cache (see the regression this guards against in - /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's own remarks). - /// - [Fact] - public async Task NewTag_InvalidatesCache() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "first build"); - Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - propertiesBefore["git.tags"].Should().BeEmpty(); - - string ancestorCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD~1"); - ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "release-1.0", ancestorCommitId); - tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); - - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result2, "second build"); - Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - 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", "HEAD is exactly one commit ahead of the tagged ancestor."); - - // "release-1.0-1", not the raw "git describe" output ("release-1.0-1-g"): - // git.commit.id.describe deliberately omits the abbreviated SHA - see - // GenerateGitPropertiesCacheTask.ParseDescribeOutput's own BaseDescribe reconstruction. - propertiesAfter["git.commit.id.describe"].Should().Be("release-1.0-1"); - } -} - -public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task Publish_IncludesGitProperties() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish"); - AssertBuildSucceeded(result, "publish"); - result.Output.Should().NotContain("duplicate"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - } -} - -public sealed class PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task Publish_NoBuild_IncludesGitProperties() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult buildResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-c", "Release"); - AssertBuildSucceeded(buildResult, "build"); - - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-c", "Release", "--no-build"); - AssertBuildSucceeded(publishResult, "publish --no-build"); - publishResult.Output.Should().NotContain("duplicate"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - } -} - -public sealed class NoGitWarnsByDefaultTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task NoGit_WarnsByDefault() - { - string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); - Directory.CreateDirectory(projectDirectory); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "the build with no .git present"); - AssertWarned(result, "GITPROPS001"); - AssertNoGitPropertiesGenerated(testApp); - } -} - -public sealed class NoGitInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task NoGit_InfoWhenEnableWarningsFalse() - { - string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); - Directory.CreateDirectory(projectDirectory); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertBuildSucceeded(result, "build"); - AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); - AssertNoGitPropertiesGenerated(testApp); - } -} - -public sealed class GitFileWarnsByDefaultTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task GitFile_WarnsByDefault() - { - string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); - Directory.CreateDirectory(projectDirectory); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk - // (which starts at TestApp, the project actually being built) to find it - i.e. at - // projectDirectory itself. - await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj", TestContext.Current.CancellationToken); - - ProcessResult defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(defaultResult, "the build with .git as a file (a worktree/submodule checkout - e.g. an AI agent - must never fail)"); - AssertWarned(defaultResult, "GITPROPS002"); - AssertNoGitPropertiesGenerated(testApp); - - ProcessResult enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertBuildSucceeded(enableWarningsFalseResult, "build"); - AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS002", "resolves to a git worktree or submodule"); - - ProcessResult featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); - AssertBuildSucceeded(featureOffResult, "build with GenerateGitProperties=false"); - featureOffResult.Output.Should().NotContain("GITPROPS002"); - } -} - -public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildTestBase -{ - /// - /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a fork's - /// "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that when origin - /// itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config --list"'s own - /// last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). The winning URL is - /// deliberately given embedded credentials, folding StripUserInfo's own coverage into this same build rather than spinning up a dedicated test just for - /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. - /// - [Fact] - public async Task MultipleRemotes_OnlyOriginUrlIsUsed() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunGitAsync(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); - await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "https://example.com/origin.git"); - await ProcessRunner.RunGitAsync(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build with multiple remotes configured"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", - "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + - "'user:pass@' credentials must be stripped before the value ever reaches the cache file."); - } -} - -public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task ShallowClone_LeavesCommitCountsEmpty() - { - string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 3); - await ProcessRunner.RunGitAsync(source, "tag", "v1.0.0"); - - string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); - // --no-local is required here: for a plain local filesystem path, git's local-clone - // optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, - // producing a full clone that would make this test worthless. - ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); - string isShallowRepository = await ProcessRunner.GetGitOutputAsync(shallow, "rev-parse", "--is-shallow-repository"); - isShallowRepository.Should().Be("true"); - - string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "the build against a shallow clone"); - result.Output.Should().NotContain("GITPROPS001"); - result.Output.Should().NotContain("GITPROPS002"); - AssertWarned(result, "GITPROPS006"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - properties["git.total.commit.count"].Should().BeEmpty(); - properties["git.closest.tag.commit.count"].Should().BeEmpty(); - } -} - -public sealed class ShallowCloneInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase -{ - /// - /// GITPROPS006 (unlike GITPROPS001-005) never blocks generation - the shallow clone is still fully usable, just with two fields left empty (see - /// ). Confirms $(GitPropertiesEnableWarnings) downgrades it to an informational message the same - /// way it does for the others. - /// - [Fact] - public async Task ShallowClone_InfoWhenEnableWarningsFalse() - { - string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 1); - - string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); - ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); - - string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertBuildSucceeded(result, "build"); - AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); - } -} - -public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task NonAscii_CommitDataRendersCorrectly() - { - string repository = Path.Combine(Workspace.RootDirectory, "repo"); - Directory.CreateDirectory(repository); - await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); - // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 - // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). - const string nonAsciiUserName = "\u00DCn\u00EFc\u00F6d\u00E9 T\u00EBst"; - const string nonAsciiCommitMessage = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; - - await ProcessRunner.RunGitAsync(repository, "config", "user.name", nonAsciiUserName); - await ProcessRunner.RunGitAsync(repository, "config", "user.email", "test@example.com"); - await File.WriteAllTextAsync(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(repository, "file.txt"), "content", TestContext.Current.CancellationToken); - await ProcessRunner.RunGitAsync(repository, "add", "-A"); - await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitMessage); - - string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - properties["git.commit.user.name"].Should().Be(nonAsciiUserName); - properties["git.commit.message.short"].Should().Be(nonAsciiCommitMessage); - } -} - -public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task MultiProject_SharesCache() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2); - - // Two independent projects at the repo root, each with their own ProjectReference/Import - // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepo - // already placed TestApp there; reuse that exact relative layout for ProjectA/ProjectB by placing them - // at the repo root too, siblings of TestApp and "src"). - await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectA"); - await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectB"); - - string projectA = Path.Combine(repository, "ProjectA"); - string projectB = Path.Combine(repository, "ProjectB"); - - ProcessResult resultA = await ProcessRunner.RunDotnetAsync(projectA, "build", "-v:detailed"); - AssertBuildSucceeded(resultA, "ProjectA build"); - - resultA.Output.Should().Contain("git.properties: generating shared cache", - "ProjectA (first to build) should be the one that actually generates the shared cache."); - - string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); - File.Exists(cacheFile).Should().BeTrue("ProjectA's build should have generated the shared cache."); - - ProcessResult resultB = await ProcessRunner.RunDotnetAsync(projectB, "build", "-v:detailed"); - AssertBuildSucceeded(resultB, "ProjectB build"); - - // "did not log generating the cache" is itself the deterministic, sufficient proof ProjectB - // reused ProjectA's cache instead of rewriting it - no last-write-time comparison (and the - // sleep it would otherwise need) is needed on top of it. - resultB.Output.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); - - Dictionary propertiesA = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectA)); - Dictionary propertiesB = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectB)); - propertiesB["git.commit.id"].Should().Be(propertiesA["git.commit.id"]); - } -} - -public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : GitPropertiesBuildTestBase -{ - /// - /// A single multi-targeted project (current TFM plus the one immediately before it - see ) is a - /// different sharing scenario than : MSBuild builds a multi-targeted project's inner TFMs concurrently by - /// default (unlike the two sequential "dotnet build" invocations that test uses), which is exactly the race - /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's cross-process lock exists to handle. Also guards against the regression that fix's first - /// (wrong) attempt introduced: tagging the current commit invalidates the cache without changing the commit ID, so a naive "does the cache already - /// reflect this commit" freshness check would wrongly skip regenerating it. - /// - [Fact] - public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); - string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); - - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "multi-targeted build"); - - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - List> propertiesBefore = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); - - foreach (Dictionary properties in propertiesBefore) - { - properties["git.commit.id"].Should().Be(expectedCommitId); - properties["git.tags"].Should().BeEmpty(); - } - - ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "v1.0.0"); - tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); - - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result2, "second multi-targeted build"); - - List> propertiesAfter = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); - - foreach (Dictionary properties in propertiesAfter) - { - properties["git.tags"].Should().Be("v1.0.0", "both target frameworks must observe the new tag, even though the commit it points at didn't change."); - } - } -} - -public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result1, "build with GitPropertiesWriteToProjectDirectory=true"); - - result1.Output.Should().Contain( - $"git.properties: writing fallback copy to '{GetFallbackFilePath(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); - - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - Dictionary outputProperties1 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - fallbackProperties.Should().BeEquivalentTo(outputProperties1, "the fallback file must carry the exact same content as the live build output."); - - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); - - // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result2, "second build"); - - Dictionary outputProperties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - outputProperties2["git.dirty"].Should().Be("false", - "the gitignored fallback file from the first build must not make a later build see the tree as dirty."); - } -} - -public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest : GitPropertiesBuildTestBase -{ - /// - /// The negative counterpart to - proves the README's ".gitignore this file" warning - /// is describing a real consequence, not a hypothetical one: deliberately uses a repository WITHOUT the fallback file gitignored, so the file the first - /// build writes is left behind as a genuine untracked change - permanently flipping git.dirty to "true" on every later build, even though nothing about - /// the actually-tracked source changed in between. - /// - [Fact] - public async Task FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result1, "first build, which writes the (not yet gitignored) fallback file"); - - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - gitStatus.Should().NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); - - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result2, "second build"); - - Dictionary properties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - - properties2["git.dirty"].Should().Be("true", - "the ungitignored fallback file left over from the first build makes every later build see the tree as dirty."); - } -} - -public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : GitPropertiesBuildTestBase -{ - /// - /// "dotnet publish" runs its own compile/composition steps internally regardless of whether "dotnet build" ran first - this guards against the fallback - /// file only being written along the "build" target chain and silently never firing when publish is the very first command run against a fresh checkout - /// (a common real-world pattern: `dotnet publish` directly, without a separate build step). Runs with $(GitPropertiesEnableWarnings) at its default - /// (enabled) setting to confirm nothing about the fallback-writing path implicitly depends on warnings being suppressed - since a real .git repository - /// is available here, nothing should be skipped (and no GITPROPS0xx code should appear) regardless of that setting. - /// - [Fact] - public async Task WriteToProjectDirectory_CreatesFallbackFile_OnPublish() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", - "-p:GitPropertiesEnableWarnings=true"); - - AssertBuildSucceeded(result, "publish with GitPropertiesWriteToProjectDirectory=true, without an upfront build"); - - result.Output.Should().NotContain("GITPROPS0", - "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); - - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); - fallbackProperties.Should().BeEquivalentTo(publishedProperties, "the fallback file must carry the exact same content as the published output."); - - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); - } -} - -public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesBuildTestBase -{ - /// - /// Guards against a stale fallback file (left over from some earlier build) ever shadowing live generation - the fallback file must only ever be used as - /// a last resort, never preferred over a real, currently-usable .git repository. - /// - [Fact] - public async Task FallbackFile_Ignored_WhenLiveGitAvailable() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await File.WriteAllLinesAsync(GetFallbackFilePath(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], - TestContext.Current.CancellationToken); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); - AssertBuildSucceeded(result, "build with a stale fallback file present alongside a real .git repository"); - result.Output.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - } -} - -public sealed class FallbackFileUsedWhenNoGitAvailableTest : GitPropertiesBuildTestBase -{ - /// - /// End-to-end simulation of the scenario that motivated $(GitPropertiesWriteToProjectDirectory) in the first place: `cf push` using the - /// dotnet_core_buildpack directly from source, which strips ".git" from the pushed tree unconditionally (see SimulateSourcePush) - meaning live - /// generation can never run for that push, ever. A pre-generated fallback file (produced by an earlier LOCAL build, where .git was available) must ride - /// along in the pushed source tree and get picked up, ending up in the published output exactly as if it had been generated live. - /// - [Fact] - public async Task FallbackFile_UsedWhenNoGitAvailable() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult fallbackResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(fallbackResult, "the local build that produces the fallback file"); - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - fallbackProperties["git.dirty"].Should().Be("false", "the gitignored fallback file must not make its own producing build see the tree as dirty."); - - string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); - string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); - Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); - File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); - AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); - publishResult.Output.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); - - publishResult.Output.Should().Contain( - "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - - Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); - publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); - } -} - -public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest : GitPropertiesBuildTestBase -{ - /// - /// The stable, documented entry point for step 1 of the "Recommended cf push workflow" (see PackageReadme.md) - confirms it actually produces a usable - /// fallback file, and that doing so never compiles anything (the whole reason to prefer it over a full "dotnet build" before a source push). - /// - [Fact] - public async Task WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(result, "build -t:WriteGitPropertiesFallbackFile"); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); - Dictionary properties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - - // "bin\Debug\\publish" gets created as empty, routine SDK scaffolding even here (PrepareForPublish's own setup) - checking for the absence - // of the compiled assembly itself, not just the bin directory, is what actually proves no compilation happened. - File.Exists(Path.Combine(testApp, "bin", "Debug", TestPaths.TestAppTargetFramework, $"{GitPropertiesTestWorkspace.TestAppProjectName}.dll")).Should() - .BeFalse("this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); - } -} - -public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest : GitPropertiesBuildTestBase -{ - /// - /// End-to-end simulation of the actual documented workflow - the lightweight-target equivalent of - /// (which uses a full build instead): produce the fallback file via - /// - /// WriteGitPropertiesFallbackFile - /// - /// , simulate a source-based `cf push`, and confirm the server-side publish still picks it up correctly. - /// - [Fact] - public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - - string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); - string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); - File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); - AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); - - publishResult.Output.Should().Contain( - "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - - Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); - publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); - } -} - -public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPropertiesBuildTestBase -{ - /// - /// "--no-restore" must work the same way for this target as for any other build invocation - it only requires that restore already happened at least - /// once, same as a normal build. - /// - [Fact] - public async Task WriteGitPropertiesFallbackFile_WorksWithNoRestore() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult restoreResult = await ProcessRunner.RunDotnetAsync(testApp, "restore"); - AssertBuildSucceeded(restoreResult, "restore"); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(result, "build --no-restore -t:WriteGitPropertiesFallbackFile"); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue(); - } -} - -public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : GitPropertiesBuildTestBase -{ - /// - /// Documents/guards the one real caveat called out in PackageReadme.md: this target never produces real build output, so a local "dotnet publish - /// --no-build" afterward must fail - there is nothing compiled to publish. If this target's own implementation ever accidentally started producing - /// compiled output (defeating its "lightweight" purpose), this test would start failing for the opposite reason (publish --no-build would start - /// succeeding) - a signal to revisit the target, not just delete this test. - /// - [Fact] - public async Task WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); - - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "--no-build"); - - publishResult.ExitCode.Should().NotBe(0, - "publishing --no-build after only writing the fallback file (no real build ever ran) must fail - there is no compiled output to publish."); - } -} - -public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest : GitPropertiesBuildTestBase -{ - /// - /// Every other test here consumes Steeltoe.Management.GitProperties.Build straight from source (ProjectReference + Import) - this is the only one that - /// goes through a real, packed .nupkg via <PackageReference>, the way an actual external user of the package would. That exercises the NuGet - /// "build\{PackageId}.targets" auto-import convention end-to-end (no explicit <Import> anywhere in the consumer project) and the in-process - /// (non-dev-loop) task-loading branch (SourceCheckout.txt is never packed, so it's absent in this layout - see $(GitPropertiesTaskHost) in - /// Steeltoe.Management.GitProperties.Build.targets). Isolated per andrewlock.net's "Creating a source generator, part 3" approach: a local folder feed - /// (just our own freshly-packed .nupkg, via a nuget.config with <clear/>) and a per-test RestorePackagesPath, so this never touches - or gets a - /// stale result from - the machine-wide global-packages cache at %userprofile%\.nuget\packages. - /// - [Fact] - public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - - string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); - string packageId = await TestPaths.GetPackageIdAsync(); - - string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); - nuPkgFiles.Should().ContainSingle("packing should produce exactly one .nupkg."); - - 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("the .nupkg file name should embed the package version."); - string packageVersion = versionMatch.Groups[1].Value; - - string consumerDirectory = Path.Combine(repository, "Consumer"); - await GitPropertiesTestWorkspace.CreatePackageConsumerProjectAsync(consumerDirectory, packageVersion); - await GitPropertiesTestWorkspace.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); - - string isolatedPackagesPath = Path.Combine(Workspace.RootDirectory, "isolated-packages"); - ProcessResult result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); - AssertBuildSucceeded(result, "the build of a project consuming Steeltoe.Management.GitProperties.Build via PackageReference"); - - result.Output.Should().Contain("0 Warning(s)", - "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); - - // NuGet always lowercases the package ID for the on-disk global-packages-folder layout - this isn't - // an arbitrary case normalization, so ToUpperInvariant() (as generally preferred) would look here for - // a folder that NuGet never creates. -#pragma warning disable S4040 - string lowerCasePackageId = packageId.ToLowerInvariant(); -#pragma warning restore S4040 - - Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should().BeTrue( - "the package should restore into the isolated path, never the machine-wide global-packages cache."); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(consumerDirectory)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - } -} - -public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest : GitPropertiesBuildTestBase -{ - /// - /// The default case for the overwhelming majority of projects in a large solution (a class library, a test project, anything without a consuming package - /// anywhere in its resolved dependency graph): generation is skipped entirely, without needing an explicit opt-out, and without breaking the build. A - /// real git repository is deliberately present here (unlike NoGitWarnsByDefaultTest) to prove the smart default - not "no .git found" - is what causes - /// the skip. - /// - [Fact] - public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - - string testApp = - await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); - AssertBuildSucceeded(result, "build with no consuming-package reference and $(GenerateGitProperties) left at its smart default"); - // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). - result.Output.Should().Contain("git.properties generation skipped: no reference to"); - AssertNoGitPropertiesGenerated(testApp); - } -} - -public sealed class SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest : GitPropertiesBuildTestBase -{ - /// - /// The positive counterpart to : a project referencing the real default - /// consuming package ID (Steeltoe.Management.Endpoint) gets git.properties generated with no explicit $(GenerateGitProperties) needed. Uses a minimal - /// stand-in project with that exact name/PackageId (see WriteDummyDependencyProjectAsync's remarks) rather than the real, large Endpoint project, so - /// this test stays fast and fully offline. - /// - [Fact] - public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, - extraItemGroupContent: $""""""); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build with a Steeltoe.Management.Endpoint reference and $(GenerateGitProperties) left at its smart default"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - } -} - -public sealed class SmartDefaultOverrideDetectsCustomPackageIdsTest : GitPropertiesBuildTestBase -{ - /// - /// Proves $(GitPropertiesConsumingPackageIds) is genuinely overridable - for consumers of this package who don't use Steeltoe.Management.Endpoint at all - /// (e.g. a hand-rolled /info endpoint reading git.properties directly), so the smart default isn't hardcoded away from them. - /// - [Fact] - public async Task SmartDefault_Override_DetectsCustomPackageIds() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string customPackageId = "Contoso.Actuators"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, customPackageId); - - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, extraItemGroupContent: $""""""); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); - AssertBuildSucceeded(result, "build with a custom $(GitPropertiesConsumingPackageIds) matching a referenced project"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - properties["git.commit.id"].Should().Be(expectedCommitId); - } -} - -public sealed class SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest : GitPropertiesBuildTestBase -{ - /// - /// Guards against a regression to a naive substring match (e.g. "IndexOf(id + "/")" without also requiring the match to be a whole library key) - a - /// project referencing only "Some2" (never "Some" itself) must NOT be detected when $(GitPropertiesConsumingPackageIds) is configured as "Some", even - /// though "Some2" starts with "Some". Proves DetectConsumingPackageReferenceTask compares whole package IDs, not prefixes. - /// - [Fact] - public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string longerPackageId = "Some2"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, longerPackageId); - - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, extraItemGroupContent: $""""""); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); - AssertBuildSucceeded(result, "build with a referenced package ('Some2') that is a superstring, not a match, of the configured ID ('Some')"); - AssertNoGitPropertiesGenerated(testApp); - } -} - -public sealed class SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest : GitPropertiesBuildTestBase -{ - /// - /// Guards against a regression where MSBuild's required-parameter check for a Task string parameter treats an empty string the same as "not supplied": - /// setting $(GitPropertiesConsumingPackageIds) to blank via a global property (e.g. "-p:GitPropertiesConsumingPackageIds=") reaches - /// DetectConsumingPackageReferenceTask.PackageIds unchanged (global properties can't be reassigned by the project's own conditional default at - /// ResolveGitPropertiesPaths above), so PackageIds must NOT be [Required] - it must instead behave exactly like "no configured ID happens to match", - /// i.e. skip generation gracefully rather than fail the build with MSB4044. - /// - [Fact] - public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, - extraItemGroupContent: $""""""); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); - AssertBuildSucceeded(result, "build with $(GitPropertiesConsumingPackageIds) explicitly cleared via a global property"); - AssertNoGitPropertiesGenerated(testApp); - } -} - -public sealed class SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest : GitPropertiesBuildTestBase -{ - /// - /// A consumer's explicit choice must never be second-guessed by the smart default, in either direction - the negative direction (no reference, but - /// explicitly forced on) is already exercised by every other test in this file, which all set $(GenerateGitProperties)=true explicitly via - /// WriteAppProject's default. This covers the other direction: a consuming-package reference IS present (the smart default would say "generate"), but - /// the consumer explicitly opted out anyway. - /// - [Fact] - public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() - { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, - extraItemGroupContent: $""""""); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); - AssertBuildSucceeded(result, "build with GenerateGitProperties explicitly set to false despite a consuming-package reference being present"); - AssertNoGitPropertiesGenerated(testApp); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs new file mode 100644 index 0000000000..e6b327a4fc --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs @@ -0,0 +1,116 @@ +// 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; + +// Automated regression tests for the Steeltoe.Management.GitProperties.Build project +// (build/Steeltoe.Management.GitProperties.Build.targets plus its compiled MSBuild tasks). Exercises the +// scenarios that were manually verified while building this feature: ground-truth property values, +// incremental cache behavior, publish (with and without a prior build), the warn/info/skip diagnostic +// paths, shallow clones, non-ASCII commit data, and cross-project cache sharing. Every test runs against +// an isolated temporary workspace containing the CURRENT source of Steeltoe.Management.GitProperties.Build +// (see GitPropertiesTestWorkspace), not a stale git-tracked copy, so it always exercises whatever is on +// disk right now. Every git repository a test operates against is a small, synthetic one created from +// scratch (`git init` plus a handful of manufactured commits) - never a clone of this (large, real) +// repository - so the suite stays fast. Nothing here touches this repository's own working tree. +// +// One class per test (see GitPropertiesBuildTestBase's own remarks for why) rather than many [Fact] +// methods on one shared class: xUnit v3 runs different test classes concurrently by default, but never +// parallelizes methods within the same class. Every test here is dominated by "dotnet build"/"publish" +// subprocess time that's mostly I/O/wait-bound, not CPU-bound - splitting this way lets the whole suite's +// wall-clock approach its single slowest test instead of the sum of all of them. +// +// Measured (TRX per-test timing, sequential run): every test costs roughly 3.7-4.2 seconds PER "dotnet +// build"/"publish" subprocess it spawns, almost regardless of git-repository complexity - even the two +// tests with no git repository at all still cost ~3.7s each, matching the single-build cases with a real +// repository. Git setup (git init, commits, tags, config) is comparatively free by contrast. When a new +// scenario needs only one extra assertion against a plain default build, prefer folding it into an +// existing test that already builds one (see GroundTruthAllPropertiesMatchGitTest's own remarks for an +// example) over adding a dedicated test that pays for another subprocess just to re-run the identical +// setup. +public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTestBase +{ + /// + /// Also folds in two other checks against this same build, rather than paying for a dedicated "dotnet build" subprocess (by far the dominant cost of any + /// test in this suite - see the class remarks) just to exercise a single extra assertion against an otherwise identical, plain default build: that the + /// fallback file is never written unless explicitly opted into (see WriteToProjectDirectoryCreatesFallbackFileOnBuildTest for the positive case, which - + /// unlike this one - genuinely needs its own build, since it passes a different property), and that writing git.properties is confirmed at default + /// verbosity. + /// + [Fact] + public async Task GroundTruth_AllPropertiesMatchGit() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 3); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result, "build"); + + File.Exists(GetFallbackFilePath(testApp)).Should().BeFalse( + "the fallback file must not be written into the project directory unless explicitly opted into."); + + string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + result.Output.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + + string expectedCommitIdAbbrev = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "--short=7", "HEAD"); + properties["git.commit.id.abbrev"].Should().Be(expectedCommitIdAbbrev); + + string expectedCommitUserName = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%an"); + properties["git.commit.user.name"].Should().Be(expectedCommitUserName); + + string expectedCommitUserEmail = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%ae"); + properties["git.commit.user.email"].Should().Be(expectedCommitUserEmail); + + string expectedCommitMessageShort = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%s"); + properties["git.commit.message.short"].Should().Be(expectedCommitMessageShort); + + string expectedTotalCommitCount = await ProcessRunner.GetGitOutputAsync(repository, "rev-list", "--count", "HEAD"); + properties["git.total.commit.count"].Should().Be(expectedTotalCommitCount); + + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + bool expectedDirty = gitStatus.Length > 0; + 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), "git.build.time must reflect roughly when this build actually ran."); + + 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/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs new file mode 100644 index 0000000000..ac6ee38f65 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.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; + +public sealed class IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task IncrementalBuild_CacheSkipsButDirtyStaysLive() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result1, "first build"); + string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); + File.Exists(cacheFile).Should().BeTrue("the cache file should exist after first build."); + + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + AssertBuildSucceeded(result2, "second build"); + + // "Skipping target" is itself the deterministic, sufficient proof that nothing rewrote the + // cache file on this second build - no last-write-time comparison (and the sleep it would + // otherwise need, to guarantee a detectably different timestamp) is needed on top of it. + result2.Output.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + properties.Should().ContainKey("git.dirty"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs new file mode 100644 index 0000000000..8fe3faac17 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.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; + +public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task MultiProject_SharesCache() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2); + + // Two independent projects at the repo root, each with their own ProjectReference/Import + // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepo + // already placed TestApp there; reuse that exact relative layout for ProjectA/ProjectB by placing them + // at the repo root too, siblings of TestApp and "src"). + await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectA"); + await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectB"); + + string projectA = Path.Combine(repository, "ProjectA"); + string projectB = Path.Combine(repository, "ProjectB"); + + ProcessResult resultA = await ProcessRunner.RunDotnetAsync(projectA, "build", "-v:detailed"); + AssertBuildSucceeded(resultA, "ProjectA build"); + + resultA.Output.Should().Contain("git.properties: generating shared cache", + "ProjectA (first to build) should be the one that actually generates the shared cache."); + + string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); + File.Exists(cacheFile).Should().BeTrue("ProjectA's build should have generated the shared cache."); + + ProcessResult resultB = await ProcessRunner.RunDotnetAsync(projectB, "build", "-v:detailed"); + AssertBuildSucceeded(resultB, "ProjectB build"); + + // "did not log generating the cache" is itself the deterministic, sufficient proof ProjectB + // reused ProjectA's cache instead of rewriting it - no last-write-time comparison (and the + // sleep it would otherwise need) is needed on top of it. + resultB.Output.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); + + Dictionary propertiesA = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectA)); + Dictionary propertiesB = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectB)); + propertiesB["git.commit.id"].Should().Be(propertiesA["git.commit.id"]); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs new file mode 100644 index 0000000000..2214f6f034 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.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. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : GitPropertiesBuildTestBase +{ + /// + /// A single multi-targeted project (current TFM plus the one immediately before it - see ) is a + /// different sharing scenario than : MSBuild builds a multi-targeted project's inner TFMs concurrently by + /// default (unlike the two sequential "dotnet build" invocations that test uses), which is exactly the race + /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's cross-process lock exists to handle. Also guards against the regression that fix's first + /// (wrong) attempt introduced: tagging the current commit invalidates the cache without changing the commit ID, so a naive "does the cache already + /// reflect this commit" freshness check would wrongly skip regenerating it. + /// + [Fact] + public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); + string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); + + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result1, "multi-targeted build"); + + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + List> propertiesBefore = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); + + foreach (Dictionary properties in propertiesBefore) + { + properties["git.commit.id"].Should().Be(expectedCommitId); + properties["git.tags"].Should().BeEmpty(); + } + + ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "v1.0.0"); + tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); + + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result2, "second multi-targeted build"); + + List> propertiesAfter = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); + + foreach (Dictionary properties in propertiesAfter) + { + properties["git.tags"].Should().Be("v1.0.0", "both target frameworks must observe the new tag, even though the commit it points at didn't change."); + } + } +} diff --git a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs new file mode 100644 index 0000000000..9b2dcd613f --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs @@ -0,0 +1,36 @@ +// 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; + +public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildTestBase +{ + /// + /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a fork's + /// "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that when origin + /// itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config --list"'s own + /// last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). The winning URL is + /// deliberately given embedded credentials, folding StripUserInfo's own coverage into this same build rather than spinning up a dedicated test just for + /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. + /// + [Fact] + public async Task MultipleRemotes_OnlyOriginUrlIsUsed() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + await ProcessRunner.RunGitAsync(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); + await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "https://example.com/origin.git"); + await ProcessRunner.RunGitAsync(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result, "build with multiple remotes configured"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", + "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + + "'user:pass@' credentials must be stripped before the value ever reaches the cache file."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs new file mode 100644 index 0000000000..ad8cd5c706 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs @@ -0,0 +1,44 @@ +// 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; + +public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase +{ + /// + /// Also folds in coverage for the trickiest shape ParseDescribeOutput's dash-splitting has to get right - a tag name that itself contains a dash + /// ("release-1.0"), combined with a nonzero commits-ahead count - rather than spinning up a dedicated test just for that. Tagging an ANCESTOR of HEAD, + /// not HEAD itself, serves both purposes at once: it produces that nonzero count, and it keeps HEAD's own commit ID unchanged, which is the actual point + /// of this test's name - proving a new tag ref alone still invalidates the shared cache (see the regression this guards against in + /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's own remarks). + /// + [Fact] + public async Task NewTag_InvalidatesCache() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result1, "first build"); + Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + propertiesBefore["git.tags"].Should().BeEmpty(); + + string ancestorCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD~1"); + ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "release-1.0", ancestorCommitId); + tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); + + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result2, "second build"); + Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + 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", "HEAD is exactly one commit ahead of the tagged ancestor."); + + // "release-1.0-1", not the raw "git describe" output ("release-1.0-1-g"): + // git.commit.id.describe deliberately omits the abbreviated SHA - see + // GenerateGitPropertiesCacheTask.ParseDescribeOutput's own BaseDescribe reconstruction. + propertiesAfter["git.commit.id.describe"].Should().Be("release-1.0-1"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs new file mode 100644 index 0000000000..9133d0bffa --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.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; + +public sealed class NoGitInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task NoGit_InfoWhenEnableWarningsFalse() + { + string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); + Directory.CreateDirectory(projectDirectory); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertBuildSucceeded(result, "build"); + AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); + AssertNoGitPropertiesGenerated(testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs new file mode 100644 index 0000000000..802917fe9e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.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; + +public sealed class NoGitWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task NoGit_WarnsByDefault() + { + string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); + Directory.CreateDirectory(projectDirectory); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result, "the build with no .git present"); + AssertWarned(result, "GITPROPS001"); + AssertNoGitPropertiesGenerated(testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs new file mode 100644 index 0000000000..e9cfaee100 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -0,0 +1,36 @@ +// 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; + +public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task NonAscii_CommitDataRendersCorrectly() + { + string repository = Path.Combine(Workspace.RootDirectory, "repo"); + Directory.CreateDirectory(repository); + await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); + // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 + // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). + const string nonAsciiUserName = "\u00DCn\u00EFc\u00F6d\u00E9 T\u00EBst"; + const string nonAsciiCommitMessage = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; + + await ProcessRunner.RunGitAsync(repository, "config", "user.name", nonAsciiUserName); + await ProcessRunner.RunGitAsync(repository, "config", "user.email", "test@example.com"); + await File.WriteAllTextAsync(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(repository, "file.txt"), "content", TestContext.Current.CancellationToken); + await ProcessRunner.RunGitAsync(repository, "add", "-A"); + await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitMessage); + + string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result, "build"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + properties["git.commit.user.name"].Should().Be(nonAsciiUserName); + properties["git.commit.message.short"].Should().Be(nonAsciiCommitMessage); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs new file mode 100644 index 0000000000..28e89167e4 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -0,0 +1,61 @@ +// 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; + +public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest : GitPropertiesBuildTestBase +{ + /// + /// Every other test here consumes Steeltoe.Management.GitProperties.Build straight from source (ProjectReference + Import) - this is the only one that + /// goes through a real, packed .nupkg via <PackageReference>, the way an actual external user of the package would. That exercises the NuGet + /// "build\{PackageId}.targets" auto-import convention end-to-end (no explicit <Import> anywhere in the consumer project) and the in-process + /// (non-dev-loop) task-loading branch (SourceCheckout.txt is never packed, so it's absent in this layout - see $(GitPropertiesTaskHost) in + /// Steeltoe.Management.GitProperties.Build.targets). Isolated per andrewlock.net's "Creating a source generator, part 3" approach: a local folder feed + /// (just our own freshly-packed .nupkg, via a nuget.config with <clear/>) and a per-test RestorePackagesPath, so this never touches - or gets a + /// stale result from - the machine-wide global-packages cache at %userprofile%\.nuget\packages. + /// + [Fact] + public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + + string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); + string packageId = await TestPaths.GetPackageIdAsync(); + + string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); + nuPkgFiles.Should().ContainSingle("packing should produce exactly one .nupkg."); + + 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("the .nupkg file name should embed the package version."); + string packageVersion = versionMatch.Groups[1].Value; + + string consumerDirectory = Path.Combine(repository, "Consumer"); + await GitPropertiesTestWorkspace.CreatePackageConsumerProjectAsync(consumerDirectory, packageVersion); + await GitPropertiesTestWorkspace.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); + + string isolatedPackagesPath = Path.Combine(Workspace.RootDirectory, "isolated-packages"); + ProcessResult result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); + AssertBuildSucceeded(result, "the build of a project consuming Steeltoe.Management.GitProperties.Build via PackageReference"); + + result.Output.Should().Contain("0 Warning(s)", + "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); + + // NuGet always lowercases the package ID for the on-disk global-packages-folder layout - this isn't + // an arbitrary case normalization, so ToUpperInvariant() (as generally preferred) would look here for + // a folder that NuGet never creates. +#pragma warning disable S4040 + string lowerCasePackageId = packageId.ToLowerInvariant(); +#pragma warning restore S4040 + + Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should().BeTrue( + "the package should restore into the isolated path, never the machine-wide global-packages cache."); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(consumerDirectory)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs new file mode 100644 index 0000000000..71f2b4f02e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.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; + +public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Publish_IncludesGitProperties() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish"); + AssertBuildSucceeded(result, "publish"); + result.Output.Should().NotContain("duplicate"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs new file mode 100644 index 0000000000..828f2fb7ff --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.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; + +public sealed class PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task Publish_NoBuild_IncludesGitProperties() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult buildResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-c", "Release"); + AssertBuildSucceeded(buildResult, "build"); + + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-c", "Release", "--no-build"); + AssertBuildSucceeded(publishResult, "publish --no-build"); + publishResult.Output.Should().NotContain("duplicate"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs new file mode 100644 index 0000000000..d4ba3ac378 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.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; + +public sealed class ShallowCloneInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase +{ + /// + /// GITPROPS006 (unlike GITPROPS001-005) never blocks generation - the shallow clone is still fully usable, just with two fields left empty (see + /// ). Confirms $(GitPropertiesEnableWarnings) downgrades it to an informational message the same + /// way it does for the others. + /// + [Fact] + public async Task ShallowClone_InfoWhenEnableWarningsFalse() + { + string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 1); + + string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); + ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); + cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); + + string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertBuildSucceeded(result, "build"); + AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs new file mode 100644 index 0000000000..3e835e3e79 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -0,0 +1,36 @@ +// 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; + +public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task ShallowClone_LeavesCommitCountsEmpty() + { + string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 3); + await ProcessRunner.RunGitAsync(source, "tag", "v1.0.0"); + + string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); + // --no-local is required here: for a plain local filesystem path, git's local-clone + // optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, + // producing a full clone that would make this test worthless. + ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); + cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); + string isShallowRepository = await ProcessRunner.GetGitOutputAsync(shallow, "rev-parse", "--is-shallow-repository"); + isShallowRepository.Should().Be("true"); + + string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result, "the build against a shallow clone"); + result.Output.Should().NotContain("GITPROPS001"); + result.Output.Should().NotContain("GITPROPS002"); + AssertWarned(result, "GITPROPS006"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + properties["git.total.commit.count"].Should().BeEmpty(); + properties["git.closest.tag.commit.count"].Should().BeEmpty(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs new file mode 100644 index 0000000000..b99ffc324c --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.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; + +public sealed class SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest : GitPropertiesBuildTestBase +{ + /// + /// A consumer's explicit choice must never be second-guessed by the smart default, in either direction - the negative direction (no reference, but + /// explicitly forced on) is already exercised by every other test in this file, which all set $(GenerateGitProperties)=true explicitly via + /// WriteAppProject's default. This covers the other direction: a consuming-package reference IS present (the smart default would say "generate"), but + /// the consumer explicitly opted out anyway. + /// + [Fact] + public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); + AssertBuildSucceeded(result, "build with GenerateGitProperties explicitly set to false despite a consuming-package reference being present"); + AssertNoGitPropertiesGenerated(testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs new file mode 100644 index 0000000000..f524011e6c --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -0,0 +1,33 @@ +// 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; + +public sealed class SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest : GitPropertiesBuildTestBase +{ + /// + /// The positive counterpart to : a project referencing the real default + /// consuming package ID (Steeltoe.Management.Endpoint) gets git.properties generated with no explicit $(GenerateGitProperties) needed. Uses a minimal + /// stand-in project with that exact name/PackageId (see WriteDummyDependencyProjectAsync's remarks) rather than the real, large Endpoint project, so + /// this test stays fast and fully offline. + /// + [Fact] + public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertBuildSucceeded(result, "build with a Steeltoe.Management.Endpoint reference and $(GenerateGitProperties) left at its smart default"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs new file mode 100644 index 0000000000..912d0ce278 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.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; + +public sealed class SmartDefaultOverrideDetectsCustomPackageIdsTest : GitPropertiesBuildTestBase +{ + /// + /// Proves $(GitPropertiesConsumingPackageIds) is genuinely overridable - for consumers of this package who don't use Steeltoe.Management.Endpoint at all + /// (e.g. a hand-rolled /info endpoint reading git.properties directly), so the smart default isn't hardcoded away from them. + /// + [Fact] + public async Task SmartDefault_Override_DetectsCustomPackageIds() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + const string customPackageId = "Contoso.Actuators"; + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, customPackageId); + + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); + AssertBuildSucceeded(result, "build with a custom $(GitPropertiesConsumingPackageIds) matching a referenced project"); + + Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs new file mode 100644 index 0000000000..0cc2de7d85 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.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; + +public sealed class SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest : GitPropertiesBuildTestBase +{ + /// + /// Guards against a regression to a naive substring match (e.g. "IndexOf(id + "/")" without also requiring the match to be a whole library key) - a + /// project referencing only "Some2" (never "Some" itself) must NOT be detected when $(GitPropertiesConsumingPackageIds) is configured as "Some", even + /// though "Some2" starts with "Some". Proves DetectConsumingPackageReferenceTask compares whole package IDs, not prefixes. + /// + [Fact] + public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + const string longerPackageId = "Some2"; + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, longerPackageId); + + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, extraItemGroupContent: $""""""); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); + AssertBuildSucceeded(result, "build with a referenced package ('Some2') that is a superstring, not a match, of the configured ID ('Some')"); + AssertNoGitPropertiesGenerated(testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs new file mode 100644 index 0000000000..dea2c36e88 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.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; + +public sealed class SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest : GitPropertiesBuildTestBase +{ + /// + /// Guards against a regression where MSBuild's required-parameter check for a Task string parameter treats an empty string the same as "not supplied": + /// setting $(GitPropertiesConsumingPackageIds) to blank via a global property (e.g. "-p:GitPropertiesConsumingPackageIds=") reaches + /// DetectConsumingPackageReferenceTask.PackageIds unchanged (global properties can't be reassigned by the project's own conditional default at + /// ResolveGitPropertiesPaths above), so PackageIds must NOT be [Required] - it must instead behave exactly like "no configured ID happens to match", + /// i.e. skip generation gracefully rather than fail the build with MSB4044. + /// + [Fact] + public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; + await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + + string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, + generateGitProperties: null, + extraItemGroupContent: $""""""); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); + AssertBuildSucceeded(result, "build with $(GitPropertiesConsumingPackageIds) explicitly cleared via a global property"); + AssertNoGitPropertiesGenerated(testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs new file mode 100644 index 0000000000..295b177330 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.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; + +public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest : GitPropertiesBuildTestBase +{ + /// + /// The default case for the overwhelming majority of projects in a large solution (a class library, a test project, anything without a consuming package + /// anywhere in its resolved dependency graph): generation is skipped entirely, without needing an explicit opt-out, and without breaking the build. A + /// real git repository is deliberately present here (unlike NoGitWarnsByDefaultTest) to prove the smart default - not "no .git found" - is what causes + /// the skip. + /// + [Fact] + public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + + string testApp = + await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + AssertBuildSucceeded(result, "build with no consuming-package reference and $(GenerateGitProperties) left at its smart default"); + // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). + result.Output.Should().Contain("git.properties generation skipped: no reference to"); + AssertNoGitPropertiesGenerated(testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs new file mode 100644 index 0000000000..72db4f4fd4 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.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; + +public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest : GitPropertiesBuildTestBase +{ + /// + /// The stable, documented entry point for step 1 of the "Recommended cf push workflow" (see PackageReadme.md) - confirms it actually produces a usable + /// fallback file, and that doing so never compiles anything (the whole reason to prefer it over a full "dotnet build" before a source push). + /// + [Fact] + public async Task WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(result, "build -t:WriteGitPropertiesFallbackFile"); + + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + Dictionary properties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + properties["git.commit.id"].Should().Be(expectedCommitId); + + // "bin\Debug\\publish" gets created as empty, routine SDK scaffolding even here (PrepareForPublish's own setup) - checking for the absence + // of the compiled assembly itself, not just the bin directory, is what actually proves no compilation happened. + File.Exists(Path.Combine(testApp, "bin", "Debug", TestPaths.TestAppTargetFramework, $"{GitPropertiesTestWorkspace.TestAppProjectName}.dll")).Should() + .BeFalse("this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs new file mode 100644 index 0000000000..85999638d8 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.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; + +public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : GitPropertiesBuildTestBase +{ + /// + /// Documents/guards the one real caveat called out in PackageReadme.md: this target never produces real build output, so a local "dotnet publish + /// --no-build" afterward must fail - there is nothing compiled to publish. If this target's own implementation ever accidentally started producing + /// compiled output (defeating its "lightweight" purpose), this test would start failing for the opposite reason (publish --no-build would start + /// succeeding) - a signal to revisit the target, not just delete this test. + /// + [Fact] + public async Task WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); + + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "--no-build"); + + publishResult.ExitCode.Should().NotBe(0, + "publishing --no-build after only writing the fallback file (no real build ever ran) must fail - there is no compiled output to publish."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs new file mode 100644 index 0000000000..0937723485 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -0,0 +1,40 @@ +// 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; + +public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest : GitPropertiesBuildTestBase +{ + /// + /// End-to-end simulation of the actual documented workflow - the lightweight-target equivalent of + /// (which uses a full build instead): produce the fallback file via + /// + /// WriteGitPropertiesFallbackFile + /// + /// , simulate a source-based `cf push`, and confirm the server-side publish still picks it up correctly. + /// + [Fact] + public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + + string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); + string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); + File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + + ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); + AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); + + publishResult.Output.Should().Contain( + "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + + Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); + publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs new file mode 100644 index 0000000000..6d80efc76b --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.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; + +public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPropertiesBuildTestBase +{ + /// + /// "--no-restore" must work the same way for this target as for any other build invocation - it only requires that restore already happened at least + /// once, same as a normal build. + /// + [Fact] + public async Task WriteGitPropertiesFallbackFile_WorksWithNoRestore() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult restoreResult = await ProcessRunner.RunDotnetAsync(testApp, "restore"); + AssertBuildSucceeded(restoreResult, "restore"); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); + AssertBuildSucceeded(result, "build --no-restore -t:WriteGitPropertiesFallbackFile"); + + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs new file mode 100644 index 0000000000..391e5e2cbe --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.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. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitPropertiesBuildTestBase +{ + [Fact] + public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result1, "build with GitPropertiesWriteToProjectDirectory=true"); + + result1.Output.Should().Contain( + $"git.properties: writing fallback copy to '{GetFallbackFilePath(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + Dictionary outputProperties1 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + fallbackProperties.Should().BeEquivalentTo(outputProperties1, "the fallback file must carry the exact same content as the live build output."); + + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + + // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. + ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + AssertBuildSucceeded(result2, "second build"); + + Dictionary outputProperties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + outputProperties2["git.dirty"].Should().Be("false", + "the gitignored fallback file from the first build must not make a later build see the tree as dirty."); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs new file mode 100644 index 0000000000..d42d134b92 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.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. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : GitPropertiesBuildTestBase +{ + /// + /// "dotnet publish" runs its own compile/composition steps internally regardless of whether "dotnet build" ran first - this guards against the fallback + /// file only being written along the "build" target chain and silently never firing when publish is the very first command run against a fresh checkout + /// (a common real-world pattern: `dotnet publish` directly, without a separate build step). Runs with $(GitPropertiesEnableWarnings) at its default + /// (enabled) setting to confirm nothing about the fallback-writing path implicitly depends on warnings being suppressed - since a real .git repository + /// is available here, nothing should be skipped (and no GITPROPS0xx code should appear) regardless of that setting. + /// + [Fact] + public async Task WriteToProjectDirectory_CreatesFallbackFile_OnPublish() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", + "-p:GitPropertiesEnableWarnings=true"); + + AssertBuildSucceeded(result, "publish with GitPropertiesWriteToProjectDirectory=true, without an upfront build"); + + result.Output.Should().NotContain("GITPROPS0", + "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); + + File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); + + Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); + fallbackProperties.Should().BeEquivalentTo(publishedProperties, "the fallback file must carry the exact same content as the published output."); + + string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); + gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + } +} From cd72d840ebb1a0d175bfc6a184140b3762a07517 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:47:46 +0200 Subject: [PATCH 10/20] Test improvements --- .../GenerateGitPropertiesCacheTask.cs | 7 +- ...toe.Management.GitProperties.Build.targets | 26 +++++--- ...ChangesAcrossBuildsUnlikeCommitTimeTest.cs | 6 +- ...backFileIgnoredWhenLiveGitAvailableTest.cs | 5 +- .../FallbackFileUsedWhenNoGitAvailableTest.cs | 12 ++-- ...itignoreMakesLaterBuildsAppearDirtyTest.cs | 6 +- ...GitExecutableNotFoundWarnsByDefaultTest.cs | 37 +++++++++++ .../GitFileWarnsByDefaultTest.cs | 11 ++-- .../GitPropertiesBuildTestBase.cs | 25 +++---- .../GitPropertiesTestWorkspace.cs | 12 +--- .../GroundTruthAllPropertiesMatchGitTest.cs | 5 +- ...talBuildCacheSkipsButDirtyStaysLiveTest.cs | 8 +-- .../MultiProjectSharesCacheTest.cs | 10 ++- ...ctSharesCacheAcrossTargetFrameworksTest.cs | 9 +-- .../MultipleRemotesOnlyOriginUrlIsUsedTest.cs | 26 +++++++- .../NewTagInvalidatesCacheTest.cs | 9 +-- .../NoCommitsWarnsByDefaultTest.cs | 31 +++++++++ .../NoGitInfoWhenEnableWarningsFalseTest.cs | 3 +- .../NoGitWarnsByDefaultTest.cs | 3 +- .../NonAsciiCommitDataRendersCorrectlyTest.cs | 19 ++++-- ...kageReferenceGeneratesGitPropertiesTest.cs | 7 +- .../GitProperties.Build.Test/ProcessResult.cs | 7 -- .../GitProperties.Build.Test/ProcessRunner.cs | 65 +++++++++++++++---- .../PublishIncludesGitPropertiesTest.cs | 5 +- ...PublishNoBuildIncludesGitPropertiesTest.cs | 8 +-- ...lowCloneInfoWhenEnableWarningsFalseTest.cs | 6 +- ...ShallowCloneLeavesCommitCountsEmptyTest.cs | 10 ++- ...erDetectedConsumingPackageReferenceTest.cs | 3 +- ...rtiesWhenConsumingPackageReferencedTest.cs | 3 +- ...aultOverrideDetectsCustomPackageIdsTest.cs | 3 +- ...errideDoesNotMatchPackageIdAsPrefixTest.cs | 3 +- ...alPropertySkipsGenerationGracefullyTest.cs | 3 +- ...tionWhenNoConsumingPackageReferenceTest.cs | 5 +- .../GitProperties.Build.Test/TestPaths.cs | 10 +-- ...roducesFallbackFileWithoutCompilingTest.cs | 3 +- ...FallbackFileThenPublishNoBuildFailsTest.cs | 11 ++-- ...henSimulatedPushServerPublishUsesItTest.cs | 10 +-- ...rtiesFallbackFileWorksWithNoRestoreTest.cs | 7 +- ...DirectoryCreatesFallbackFileOnBuildTest.cs | 8 +-- ...rectoryCreatesFallbackFileOnPublishTest.cs | 7 +- 40 files changed, 258 insertions(+), 196 deletions(-) create mode 100644 src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/ProcessResult.cs diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index 5d780ad03d..ea5eb40d67 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -149,9 +149,10 @@ public override bool Execute() /// with "unknown option" on one). /// /// - /// The "too old"/"unparseable" paths are both untested against a real git binary, for the same reason the git-not-runnable-at-all case always has been: - /// reliably faking a fully working-but-old (or malformed-version) git installation across Windows/Linux/macOS in this suite's plain "spawn a real dotnet - /// build" test style isn't practical. + /// The "too old"/"unparseable" paths are both untested against a real git binary: reliably faking a fully working-but-old (or malformed-version) git + /// installation across Windows/Linux/macOS in this suite's plain "spawn a real dotnet build" test style isn't practical. The git-not-runnable-at-all + /// case doesn't share that problem - pointing $(GitExecutable) at a name that can never resolve on any platform's PATH is enough - see + /// GitExecutableNotFoundWarnsByDefaultTest. /// private GitVersionStatus CheckGitVersion() { 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 index 0c42695850..aff89255b8 100644 --- a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -241,18 +241,24 @@ + glob, which simply matches nothing if its directory is absent or empty. packed-refs and + config are both commonly absent/unwritten between commits, so all the literal paths here + are only added when they actually exist. + .git\config is included alongside HEAD/refs: git.remote.origin.url, git.commit.user.name, + and git.commit.user.email are all read from git's own config output, not derived from + HEAD/refs at all - without this, e.g. reconfiguring "origin"'s URL (or committer identity) + with no new commit in between left the cache stale, still serving the previous config's + values. + Deliberately excludes .git\index: it's irrelevant to every field cached here (the only field + that depends on the working tree/index is git.dirty, which is never cached - see + ComposeGitProperties). Including it caused real, observed cache thrashing: the live "git + status" call (run on every build for the dirty check) can itself rewrite .git\index - e.g. + git commonly refreshes its stat cache the first time status runs after a fresh clone - which + touched a newer mtime than the just-written cache and forced a full, unnecessary + regeneration on the very next build. --> <_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\**\*" /> diff --git a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs index fbf91eda7e..1f35694150 100644 --- a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs +++ b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs @@ -18,8 +18,7 @@ public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "first build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); // git.build.time is formatted "yyyy-MM-ddTHH:mm:sszzz" (ComposeGitPropertiesTask) - second @@ -28,8 +27,7 @@ public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() // sized to that format's own precision, not incidental slack. await Task.Delay(TimeSpan.FromMilliseconds(1100), TestContext.Current.CancellationToken); - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result2, "second build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"], diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs index 332cb65f40..a1421e8edf 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs @@ -19,9 +19,8 @@ public async Task FallbackFile_Ignored_WhenLiveGitAvailable() await File.WriteAllLinesAsync(GetFallbackFilePath(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], TestContext.Current.CancellationToken); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); - AssertBuildSucceeded(result, "build with a stale fallback file present alongside a real .git repository"); - result.Output.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + result.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs index 2357ab00a4..5628796659 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs @@ -18,8 +18,7 @@ public async Task FallbackFile_UsedWhenNoGitAvailable() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult fallbackResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(fallbackResult, "the local build that produces the fallback file"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); fallbackProperties["git.dirty"].Should().Be("false", "the gitignored fallback file must not make its own producing build see the tree as dirty."); @@ -28,12 +27,9 @@ public async Task FallbackFile_UsedWhenNoGitAvailable() Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); - AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); - publishResult.Output.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); - - publishResult.Output.Should().Contain( - "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + string publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); + publishResult.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); + publishResult.Should().Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs index aac6102f2e..6ddd9c77bc 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs @@ -18,14 +18,12 @@ public async Task FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result1, "first build, which writes the (not yet gitignored) fallback file"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); gitStatus.Should().NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result2, "second build"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); Dictionary properties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); diff --git a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs new file mode 100644 index 0000000000..9b8436985d --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.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. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +public sealed class GitExecutableNotFoundWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + private const string BogusGitExecutable = "this-executable-definitely-does-not-exist-anywhere"; + + /// + /// $(GitExecutable) itself failing to run - the most likely real-world reason git.properties would ever be skipped at all: git simply isn't installed, + /// or isn't on PATH - is otherwise untested (see GenerateGitPropertiesCacheTask.CheckGitVersion's own remarks on why the "too old"/"unparseable version + /// string" siblings of this same check can't reasonably be exercised this way). Unlike those two, this one needs no real-but-fake git binary: pointing + /// $(GitExecutable) at a name that can never resolve on any platform's PATH reliably reproduces "could not run git at all" through a real build. + /// + [Fact] + public async Task GitExecutableNotFound_WarnsByDefault() + { + string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + + string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitExecutable={BogusGitExecutable}"); + AssertWarned(defaultResult, "GITPROPS003"); + AssertNoGitPropertiesGenerated(testApp); + + string enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitExecutable={BogusGitExecutable}", + "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + + AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS003", "could not run"); + + string featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitExecutable={BogusGitExecutable}", + "-p:GenerateGitProperties=false"); + + featureOffResult.Should().NotContain("GITPROPS003"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs index 96336441f4..92b1d0f625 100644 --- a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs @@ -17,17 +17,14 @@ public async Task GitFile_WarnsByDefault() // projectDirectory itself. await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj", TestContext.Current.CancellationToken); - ProcessResult defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(defaultResult, "the build with .git as a file (a worktree/submodule checkout - e.g. an AI agent - must never fail)"); + string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertWarned(defaultResult, "GITPROPS002"); AssertNoGitPropertiesGenerated(testApp); - ProcessResult enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertBuildSucceeded(enableWarningsFalseResult, "build"); + string enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS002", "resolves to a git worktree or submodule"); - ProcessResult featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); - AssertBuildSucceeded(featureOffResult, "build with GenerateGitProperties=false"); - featureOffResult.Output.Should().NotContain("GITPROPS002"); + string featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); + featureOffResult.Should().NotContain("GITPROPS002"); } } diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs index 82d53b531d..6ecf966d1a 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs @@ -12,11 +12,11 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// one-class-per-test lets the whole suite's wall-clock approach its slowest single test instead of the sum of all of them. /// /// -/// Public (xUnit only discovers public test classes), but every member below is internal rather than protected: ProcessResult and -/// GitPropertiesTestWorkspace are themselves internal, and a protected member of a public class can't expose a less-accessible type in its signature -/// (CS0051/CS0052). Internal still works the same way for every derived class here, since they all live in this same assembly. Implements IAsyncLifetime -/// (not a constructor) to create : GitPropertiesTestWorkspace.CreateAsync itself awaits a "pwd -P" subprocess on macOS to -/// resolve a symlink-free root path, and a constructor can't await. +/// Public (xUnit only discovers public test classes), but every member below is internal rather than protected: GitPropertiesTestWorkspace is itself +/// internal, and a protected member of a public class can't expose a less-accessible type in its signature (CS0051/CS0052). Internal still works the +/// same way for every derived class here, since they all live in this same assembly. Implements IAsyncLifetime (not a constructor) to create +/// : GitPropertiesTestWorkspace.CreateAsync itself awaits a "pwd -P" subprocess on macOS to resolve a symlink-free root path, +/// and a constructor can't await. /// public abstract class GitPropertiesBuildTestBase : IAsyncLifetime { @@ -52,14 +52,9 @@ internal static string GetFallbackFilePath(string projectDirectory) return Path.Combine(projectDirectory, "git.properties"); } - internal static void AssertBuildSucceeded(ProcessResult result, string action) + internal static void AssertWarned(string output, string code) { - result.ExitCode.Should().Be(0, "{0} should succeed. Output:\n{1}", action, result.Output); - } - - internal static void AssertWarned(ProcessResult result, string code) - { - result.Output.Should().Contain($"warning {code}"); + output.Should().Contain($"warning {code}"); } /// @@ -67,10 +62,10 @@ internal static void AssertWarned(ProcessResult result, string code) /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why), and at Importance="Normal" rather than the default's "high", so it's visible at /// "-v:normal" but not in default build output. /// - internal static void AssertReportedAsInfoOnly(ProcessResult result, string code, string messageSnippet) + internal static void AssertReportedAsInfoOnly(string output, string code, string messageSnippet) { - result.Output.Should().NotContain(code, "a downgraded message must never carry a code - only warnings do."); - result.Output.Should().Contain(messageSnippet); + output.Should().NotContain(code, "a downgraded message must never carry a code - only warnings do."); + output.Should().Contain(messageSnippet); } internal static void AssertNoGitPropertiesGenerated(string projectDirectory) diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index 69ec015216..210db14d3f 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -58,8 +58,8 @@ private static async Task ResolvePhysicalPathAsync(string path) return path; } - ProcessResult result = await ProcessRunner.RunAsync("pwd", path, TestContext.Current.CancellationToken, "-P"); - return result.Output.Trim(); + string output = await ProcessRunner.RunPwdAsync(path); + return output.Trim(); } public void Dispose() @@ -367,13 +367,7 @@ public async Task PackGitPropertiesBuildToFeedAsync() string projectDirectory = Path.Combine(packSourceDirectory, TestPaths.GitPropertiesBuildRelativePath); string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); - ProcessResult result = await ProcessRunner.RunDotnetAsync(projectDirectory, "build", Path.GetFileName(projectFile), "-c", "Release"); - - if (result.ExitCode != 0) - { - string packageId = await TestPaths.GetPackageIdAsync(); - throw new InvalidOperationException($"Building/packing {packageId} failed.\n{result.Output}"); - } + await ProcessRunner.RunDotnetAsync(projectDirectory, "build", Path.GetFileName(projectFile), "-c", "Release"); string targetFramework = await TestPaths.GetGitPropertiesBuildTargetFrameworkAsync(); return Path.Combine(projectDirectory, "bin", "tasks", targetFramework); diff --git a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs index e6b327a4fc..6c8f1cfad1 100644 --- a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs @@ -46,14 +46,13 @@ public async Task GroundTruth_AllPropertiesMatchGit() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 3); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); File.Exists(GetFallbackFilePath(testApp)).Should().BeFalse( "the fallback file must not be written into the project directory unless explicitly opted into."); string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - result.Output.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + result.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); diff --git a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs index ac6ee38f65..414aebf89d 100644 --- a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs +++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs @@ -12,18 +12,16 @@ public async Task IncrementalBuild_CacheSkipsButDirtyStaysLive() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "first build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); File.Exists(cacheFile).Should().BeTrue("the cache file should exist after first build."); - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); - AssertBuildSucceeded(result2, "second build"); + string result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); // "Skipping target" is itself the deterministic, sufficient proof that nothing rewrote the // cache file on this second build - no last-write-time comparison (and the sleep it would // otherwise need, to guarantee a detectably different timestamp) is needed on top of it. - result2.Output.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); + result2.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); properties.Should().ContainKey("git.dirty"); diff --git a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs index 8fe3faac17..f8664ac838 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs @@ -21,22 +21,20 @@ public async Task MultiProject_SharesCache() string projectA = Path.Combine(repository, "ProjectA"); string projectB = Path.Combine(repository, "ProjectB"); - ProcessResult resultA = await ProcessRunner.RunDotnetAsync(projectA, "build", "-v:detailed"); - AssertBuildSucceeded(resultA, "ProjectA build"); + string resultA = await ProcessRunner.RunDotnetAsync(projectA, "build", "-v:detailed"); - resultA.Output.Should().Contain("git.properties: generating shared cache", + resultA.Should().Contain("git.properties: generating shared cache", "ProjectA (first to build) should be the one that actually generates the shared cache."); string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); File.Exists(cacheFile).Should().BeTrue("ProjectA's build should have generated the shared cache."); - ProcessResult resultB = await ProcessRunner.RunDotnetAsync(projectB, "build", "-v:detailed"); - AssertBuildSucceeded(resultB, "ProjectB build"); + string resultB = await ProcessRunner.RunDotnetAsync(projectB, "build", "-v:detailed"); // "did not log generating the cache" is itself the deterministic, sufficient proof ProjectB // reused ProjectA's cache instead of rewriting it - no last-write-time comparison (and the // sleep it would otherwise need) is needed on top of it. - resultB.Output.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); + resultB.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); Dictionary propertiesA = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectA)); Dictionary propertiesB = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectB)); diff --git a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs index 2214f6f034..0a30abb4d6 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs @@ -21,8 +21,7 @@ public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "multi-targeted build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); List> propertiesBefore = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); @@ -33,11 +32,9 @@ public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() properties["git.tags"].Should().BeEmpty(); } - ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "v1.0.0"); - tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); + await ProcessRunner.RunGitAsync(repository, "tag", "v1.0.0"); - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result2, "second multi-targeted build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); List> propertiesAfter = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); diff --git a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs index 9b2dcd613f..6cabab8e16 100644 --- a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs @@ -12,7 +12,8 @@ public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildT /// itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config --list"'s own /// last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). The winning URL is /// deliberately given embedded credentials, folding StripUserInfo's own coverage into this same build rather than spinning up a dedicated test just for - /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. + /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. A second build, later in + /// this same test, also folds in coverage for an scp-style URL - the other shape StripUserInfo has to handle safely. /// [Fact] public async Task MultipleRemotes_OnlyOriginUrlIsUsed() @@ -24,13 +25,32 @@ public async Task MultipleRemotes_OnlyOriginUrlIsUsed() await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "https://example.com/origin.git"); await ProcessRunner.RunGitAsync(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build with multiple remotes configured"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + "'user:pass@' credentials must be stripped before the value ever reaches the cache file."); + + // A non-absolute, scp-style URL (git's other common remote syntax, alongside plain HTTPS/SSH URLs) isn't + // something Uri can parse - proves StripUserInfo leaves it untouched rather than mangling or blanking it, + // since there's nothing safe for it to rewrite. Reuses this same repository/build (a second build, not a + // dedicated test) rather than paying for a whole new synthetic repo just to reconfigure one remote - only + // possible because .git\config is itself a tracked _GitPropertiesCacheInputs entry, so this reconfiguration + // alone (no new commit needed) is enough to force the cache to regenerate on the next build. Remove-then-add + // rather than a plain "set-url": origin already carries two URLs from above (via "remote add" + "set-url + // --add"), and git refuses a plain "set-url" against a remote with multiple values ("fatal: could not set + // 'remote.origin.url' ... has multiple values") - remove-then-add is the clean way to replace all of them + // with exactly one. + await ProcessRunner.RunGitAsync(repository, "remote", "remove", "origin"); + await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "git@github.com:org/repo.git"); + + await ProcessRunner.RunDotnetAsync(testApp, "build"); + + Dictionary propertiesAfterScpStyleUrl = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + + propertiesAfterScpStyleUrl["git.remote.origin.url"].Should().Be("git@github.com:org/repo.git", + "a non-absolute, scp-style remote URL must be left exactly as-is - there is nothing safe for StripUserInfo to rewrite."); } } diff --git a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs index ad8cd5c706..5a9c021003 100644 --- a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs @@ -19,17 +19,14 @@ public async Task NewTag_InvalidatesCache() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result1, "first build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); propertiesBefore["git.tags"].Should().BeEmpty(); string ancestorCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD~1"); - ProcessResult tagResult = await ProcessRunner.RunGitAsync(repository, "tag", "release-1.0", ancestorCommitId); - tagResult.ExitCode.Should().Be(0, "creating the test tag should succeed."); + await ProcessRunner.RunGitAsync(repository, "tag", "release-1.0", ancestorCommitId); - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result2, "second build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); propertiesAfter["git.tags"].Should().BeEmpty("the tag points at an ancestor, not HEAD, so it must not show up in git.tags."); diff --git a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs new file mode 100644 index 0000000000..5d9214ad3a --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.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; + +public sealed class NoCommitsWarnsByDefaultTest : GitPropertiesBuildTestBase +{ + /// + /// A freshly-initialized repository (real ".git", so GITPROPS001/002 don't fire instead) with zero commits yet - "git rev-parse HEAD" itself fails in + /// this state, which GenerateGitPropertiesCacheTask.Preflight treats as a routine, forgivable precondition rather than an unexpected failure. + /// + [Fact] + public async Task NoCommits_WarnsByDefault() + { + string repository = Path.Combine(Workspace.RootDirectory, "repo"); + Directory.CreateDirectory(repository); + await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); + string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); + + string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); + AssertWarned(defaultResult, "GITPROPS005"); + AssertNoGitPropertiesGenerated(testApp); + + string enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS005", "no commits yet"); + + string featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); + featureOffResult.Should().NotContain("GITPROPS005"); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs index 9133d0bffa..5739cffdba 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs @@ -13,8 +13,7 @@ public async Task NoGit_InfoWhenEnableWarningsFalse() Directory.CreateDirectory(projectDirectory); string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertBuildSucceeded(result, "build"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); AssertNoGitPropertiesGenerated(testApp); } diff --git a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs index 802917fe9e..7c76915b70 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs @@ -13,8 +13,7 @@ public async Task NoGit_WarnsByDefault() Directory.CreateDirectory(projectDirectory); string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "the build with no .git present"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertWarned(result, "GITPROPS001"); AssertNoGitPropertiesGenerated(testApp); } diff --git a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs index e9cfaee100..272639553b 100644 --- a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -6,6 +6,12 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildTestBase { + /// + /// Also folds in coverage for GitPropertiesFormat.EscapeLineBreaks, rather than paying for a dedicated build just to exercise it: a commit message with + /// a body (subject + blank line + body, as illustrated in PackageReadme.md's own example output) contains real embedded newlines in git's raw "%B" + /// output, so this same commit also proves those get collapsed to a literal "\n" in the file that reaches disk - otherwise a multi-line message could + /// desynchronize the line-based "git.<key>=<value>" format by spanning more than one physical line. + /// [Fact] public async Task NonAscii_CommitDataRendersCorrectly() { @@ -15,22 +21,25 @@ public async Task NonAscii_CommitDataRendersCorrectly() // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). const string nonAsciiUserName = "\u00DCn\u00EFc\u00F6d\u00E9 T\u00EBst"; - const string nonAsciiCommitMessage = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; + const string nonAsciiCommitSubject = "\u00DCn\u00EFc\u00F6d\u00E9 t\u00EBst commit \u65E5\u672C\u8A9E"; + const string commitBody = "Adds a null check before calling Ping()."; await ProcessRunner.RunGitAsync(repository, "config", "user.name", nonAsciiUserName); await ProcessRunner.RunGitAsync(repository, "config", "user.email", "test@example.com"); await File.WriteAllTextAsync(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); await File.WriteAllTextAsync(Path.Combine(repository, "file.txt"), "content", TestContext.Current.CancellationToken); await ProcessRunner.RunGitAsync(repository, "add", "-A"); - await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitMessage); + await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitSubject, "-m", commitBody); string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); properties["git.commit.user.name"].Should().Be(nonAsciiUserName); - properties["git.commit.message.short"].Should().Be(nonAsciiCommitMessage); + properties["git.commit.message.short"].Should().Be(nonAsciiCommitSubject, "the short/subject line is never multi-line to begin with."); + + properties["git.commit.message.full"].Should().Be($@"{nonAsciiCommitSubject}\n\n{commitBody}", + "a real embedded newline must be escaped to a literal backslash-n, matching PackageReadme.md's own example output."); } } diff --git a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs index 28e89167e4..6e90bdf6db 100644 --- a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -38,11 +38,8 @@ public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitPropertie await GitPropertiesTestWorkspace.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); string isolatedPackagesPath = Path.Combine(Workspace.RootDirectory, "isolated-packages"); - ProcessResult result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); - AssertBuildSucceeded(result, "the build of a project consuming Steeltoe.Management.GitProperties.Build via PackageReference"); - - result.Output.Should().Contain("0 Warning(s)", - "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); + string result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); + result.Should().Contain("0 Warning(s)", "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); // NuGet always lowercases the package ID for the on-disk global-packages-folder layout - this isn't // an arbitrary case normalization, so ToUpperInvariant() (as generally preferred) would look here for diff --git a/src/Management/test/GitProperties.Build.Test/ProcessResult.cs b/src/Management/test/GitProperties.Build.Test/ProcessResult.cs deleted file mode 100644 index 850917c30b..0000000000 --- a/src/Management/test/GitProperties.Build.Test/ProcessResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -// 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 record ProcessResult(int ExitCode, string Output); diff --git a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs index cadb25678f..b85493b3b7 100644 --- a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -15,7 +15,7 @@ internal static class ProcessRunner { private static readonly string LocatorCommand = OperatingSystem.IsWindows() ? "where" : "which"; - private static readonly char[]? LineSeparators = + private static readonly char[] LineSeparators = [ '\r', '\n' @@ -35,7 +35,15 @@ internal static class ProcessRunner /// private static readonly Task RealGitExecutableTask = ResolveGitExecutableAsync(); - public static async Task RunAsync(string fileName, string workingDirectory, CancellationToken cancellationToken, params string[] arguments) + /// + /// The single, shared place every process this suite ever spawns gets its exit code checked - required, not defaulted, specifically so every one of + /// RunGitAsync, RunDotnetAsync, and RunPwdAsync has to make its own success expectation explicit, rather than silently inheriting whatever the last + /// caller happened to pass. Private: every real caller in this project goes through one of those three named, purpose-specific wrappers instead of this + /// directly. A silently-ignored failure here is exactly what let a broken "git remote set-url" call (rejected by git itself, exit code 128, because the + /// remote already had multiple values) pass unnoticed until a much later, more confusing assertion failed on a stale property value instead. + /// + private static async Task RunAsync(string fileName, string workingDirectory, int exitCodeExpected, CancellationToken cancellationToken, + params string[] arguments) { var outputBuilder = new StringBuilder(); object outputLock = new(); @@ -104,7 +112,11 @@ public static async Task RunAsync(string fileName, string working } string output = outputBuilder.ToString(); - return new ProcessResult(process.ExitCode, output); + + 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) { @@ -161,7 +173,7 @@ private static void KillEntireProcessTreeInBackground(int processId) /// Uses the currently-running test's own TestContext.Current.CancellationToken - the right default for every call site in this suite except one: see the /// explicit-CancellationToken overload below for why TestPaths.ResolveRepositoryRootAsync can't use this one. /// - public static Task RunGitAsync(string workingDirectory, params string[] arguments) + public static Task RunGitAsync(string workingDirectory, params string[] arguments) { return RunGitAsync(workingDirectory, TestContext.Current.CancellationToken, arguments); } @@ -171,10 +183,28 @@ public static Task RunGitAsync(string workingDirectory, params st /// see that method's own remarks (and 's, for the identical reasoning) for why a specific test's /// TestContext.Current.CancellationToken would be wrong there. /// - public static async Task RunGitAsync(string workingDirectory, CancellationToken cancellationToken, params string[] arguments) + public static async Task RunGitAsync(string workingDirectory, CancellationToken cancellationToken, params string[] arguments) { string gitExecutable = await RealGitExecutableTask; - return await RunAsync(gitExecutable, workingDirectory, cancellationToken, arguments); + return await RunAsync(gitExecutable, workingDirectory, 0, cancellationToken, arguments); + } + + /// + /// Defaults to expecting success (exit code 0), symmetric with - see the explicit-exit-code overload below + /// for the handful of tests that deliberately provoke a build/publish failure as the scenario under test. + /// + public static Task RunDotnetAsync(string workingDirectory, params string[] arguments) + { + return RunDotnetAsync(workingDirectory, 0, arguments); + } + + /// + /// For the rare test that provokes a specific dotnet build/publish failure as the very scenario under test (e.g. + /// WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest). + /// + public static Task RunDotnetAsync(string workingDirectory, int exitCodeExpected, params string[] arguments) + { + return RunAsync("dotnet", workingDirectory, exitCodeExpected, TestContext.Current.CancellationToken, BuildDotnetArguments(arguments)); } /// @@ -182,22 +212,29 @@ public static async Task RunGitAsync(string workingDirectory, Can /// and within run-to-run noise once folded into a real end-to-end TestApp build, but harmless either way here: no test in this suite asserts on analyzer /// diagnostics or NuGet audit warnings, only on this project's own GITPROPS0xx codes and plain build success/failure. /// - public static Task RunDotnetAsync(string workingDirectory, params string[] arguments) + private static string[] BuildDotnetArguments(string[] arguments) { - string[] allArguments = + return [ .. arguments, "-p:RunAnalyzers=false", "-p:NuGetAudit=false" ]; - - return RunAsync("dotnet", workingDirectory, TestContext.Current.CancellationToken, allArguments); } public static async Task GetGitOutputAsync(string workingDirectory, params string[] arguments) { - ProcessResult result = await RunGitAsync(workingDirectory, arguments); - return result.Output.Trim(); + string output = await RunGitAsync(workingDirectory, arguments); + return output.Trim(); + } + + /// + /// Used only by GitPropertiesTestWorkspace's macOS-only $TMPDIR symlink resolution (see its own remarks) - "pwd -P" is unrelated to git/dotnet, so this + /// runs it directly rather than forcing that through RunGitAsync/RunDotnetAsync. + /// + public static Task RunPwdAsync(string workingDirectory) + { + return RunAsync("pwd", workingDirectory, 0, TestContext.Current.CancellationToken, "-P"); } /// @@ -208,9 +245,9 @@ public static async Task GetGitOutputAsync(string workingDirectory, para /// private static async Task ResolveGitExecutableAsync() { - ProcessResult result = await RunAsync(LocatorCommand, Path.GetTempPath(), CancellationToken.None, "git"); + string output = await RunAsync(LocatorCommand, Path.GetTempPath(), 0, CancellationToken.None, "git"); - string firstLine = result.Output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? + string firstLine = output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? throw new InvalidOperationException($"Could not resolve the location of git via '{LocatorCommand} git'."); return firstLine.Trim(); diff --git a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs index 71f2b4f02e..192785c662 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs @@ -12,9 +12,8 @@ public async Task Publish_IncludesGitProperties() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish"); - AssertBuildSucceeded(result, "publish"); - result.Output.Should().NotContain("duplicate"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "publish"); + result.Should().NotContain("duplicate"); Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); diff --git a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs index 828f2fb7ff..c22199e32f 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs @@ -12,12 +12,10 @@ public async Task Publish_NoBuild_IncludesGitProperties() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult buildResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-c", "Release"); - AssertBuildSucceeded(buildResult, "build"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-c", "Release"); - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-c", "Release", "--no-build"); - AssertBuildSucceeded(publishResult, "publish --no-build"); - publishResult.Output.Should().NotContain("duplicate"); + string publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-c", "Release", "--no-build"); + publishResult.Should().NotContain("duplicate"); Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs index d4ba3ac378..1e2a7198c6 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs @@ -17,13 +17,11 @@ public async Task ShallowClone_InfoWhenEnableWarningsFalse() string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 1); string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); - ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); + await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertBuildSucceeded(result, "build"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); } } diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs index 3e835e3e79..8c47d92527 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -16,17 +16,15 @@ public async Task ShallowClone_LeavesCommitCountsEmpty() // --no-local is required here: for a plain local filesystem path, git's local-clone // optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, // producing a full clone that would make this test worthless. - ProcessResult cloneResult = await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - cloneResult.ExitCode.Should().Be(0, "shallow clone should succeed."); + await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); string isShallowRepository = await ProcessRunner.GetGitOutputAsync(shallow, "rev-parse", "--is-shallow-repository"); isShallowRepository.Should().Be("true"); string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "the build against a shallow clone"); - result.Output.Should().NotContain("GITPROPS001"); - result.Output.Should().NotContain("GITPROPS002"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + result.Should().NotContain("GITPROPS001"); + result.Should().NotContain("GITPROPS002"); AssertWarned(result, "GITPROPS006"); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs index b99ffc324c..be590a1a6c 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs @@ -23,8 +23,7 @@ public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageRef generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); - AssertBuildSucceeded(result, "build with GenerateGitProperties explicitly set to false despite a consuming-package reference being present"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); AssertNoGitPropertiesGenerated(testApp); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs index f524011e6c..3d53022e18 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -23,8 +23,7 @@ public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageRefere generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertBuildSucceeded(result, "build with a Steeltoe.Management.Endpoint reference and $(GenerateGitProperties) left at its smart default"); + await ProcessRunner.RunDotnetAsync(testApp, "build"); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs index 912d0ce278..af317469b2 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs @@ -20,8 +20,7 @@ public async Task SmartDefault_Override_DetectsCustomPackageIds() string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); - AssertBuildSucceeded(result, "build with a custom $(GitPropertiesConsumingPackageIds) matching a referenced project"); + await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs index 0cc2de7d85..3b2314ce1e 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs @@ -21,8 +21,7 @@ public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); - AssertBuildSucceeded(result, "build with a referenced package ('Some2') that is a superstring, not a match, of the configured ID ('Some')"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); AssertNoGitPropertiesGenerated(testApp); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs index dea2c36e88..b08c713873 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs @@ -24,8 +24,7 @@ public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGe generateGitProperties: null, extraItemGroupContent: $""""""); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); - AssertBuildSucceeded(result, "build with $(GitPropertiesConsumingPackageIds) explicitly cleared via a global property"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); AssertNoGitPropertiesGenerated(testApp); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs index 295b177330..3efc9ffb8e 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -20,10 +20,9 @@ public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); - AssertBuildSucceeded(result, "build with no consuming-package reference and $(GenerateGitProperties) left at its smart default"); + string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). - result.Output.Should().Contain("git.properties generation skipped: no reference to"); + result.Should().Contain("git.properties generation skipped: no reference to"); AssertNoGitPropertiesGenerated(testApp); } } diff --git a/src/Management/test/GitProperties.Build.Test/TestPaths.cs b/src/Management/test/GitProperties.Build.Test/TestPaths.cs index c3a14166ce..01baf95bab 100644 --- a/src/Management/test/GitProperties.Build.Test/TestPaths.cs +++ b/src/Management/test/GitProperties.Build.Test/TestPaths.cs @@ -154,13 +154,7 @@ private static string ResolveTestAppTargetFramework() private static async Task ResolveRepositoryRootAsync([CallerFilePath] string sourceFilePath = "") { string sourceDirectory = Path.GetDirectoryName(sourceFilePath) ?? throw new InvalidOperationException("Could not determine the test source directory."); - ProcessResult result = await ProcessRunner.RunGitAsync(sourceDirectory, CancellationToken.None, "rev-parse", "--show-toplevel"); - - if (result.ExitCode != 0) - { - throw new InvalidOperationException($"Could not resolve the repository root from {sourceDirectory}."); - } - - return result.Output.Trim().Replace('/', Path.DirectorySeparatorChar); + string output = await ProcessRunner.RunGitAsync(sourceDirectory, CancellationToken.None, "rev-parse", "--show-toplevel"); + return output.Trim().Replace('/', Path.DirectorySeparatorChar); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs index 72db4f4fd4..399957cb7f 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs @@ -16,8 +16,7 @@ public async Task WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCom string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(result, "build -t:WriteGitPropertiesFallbackFile"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); Dictionary properties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs index 85999638d8..937f70f52a 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs @@ -18,12 +18,11 @@ public async Task WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "--no-build"); - - publishResult.ExitCode.Should().NotBe(0, - "publishing --no-build after only writing the fallback file (no real build ever ran) must fail - there is no compiled output to publish."); + // 1, not just "nonzero": MSBuild's own long-standing, stable convention for "the build failed" (verified + // against a real "dotnet publish --no-build" in this exact no-compiled-output scenario) - checked here, + // at the point of the call, rather than via a separate assertion afterward. + await ProcessRunner.RunDotnetAsync(testApp, 1, "publish", "--no-build"); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs index 0937723485..90e7551227 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -20,19 +20,15 @@ public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublish string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult writeResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(writeResult, "build -t:WriteGitPropertiesFallbackFile"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - ProcessResult publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); - AssertBuildSucceeded(publishResult, "publish with no usable .git repository present"); - - publishResult.Output.Should().Contain( - "using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + string publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); + publishResult.Should().Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs index 6d80efc76b..88cd583fc2 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs @@ -16,11 +16,8 @@ public async Task WriteGitPropertiesFallbackFile_WorksWithNoRestore() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult restoreResult = await ProcessRunner.RunDotnetAsync(testApp, "restore"); - AssertBuildSucceeded(restoreResult, "restore"); - - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); - AssertBuildSucceeded(result, "build --no-restore -t:WriteGitPropertiesFallbackFile"); + await ProcessRunner.RunDotnetAsync(testApp, "restore"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue(); } diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs index 391e5e2cbe..e441fbb8c7 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs @@ -12,10 +12,9 @@ public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result1, "build with GitPropertiesWriteToProjectDirectory=true"); + string result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - result1.Output.Should().Contain( + result1.Should().Contain( $"git.properties: writing fallback copy to '{GetFallbackFilePath(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); @@ -28,8 +27,7 @@ public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. - ProcessResult result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - AssertBuildSucceeded(result2, "second build"); + await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); Dictionary outputProperties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs index d42d134b92..4dc9afd845 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs @@ -19,13 +19,10 @@ public async Task WriteToProjectDirectory_CreatesFallbackFile_OnPublish() string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - ProcessResult result = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", + string result = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); - AssertBuildSucceeded(result, "publish with GitPropertiesWriteToProjectDirectory=true, without an upfront build"); - - result.Output.Should().NotContain("GITPROPS0", - "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); + result.Should().NotContain("GITPROPS0", "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); From 84877708f9a718621d84b57fdec27de3e1b42e74 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:44:26 +0200 Subject: [PATCH 11/20] Code cleaning --- .../ComposeGitPropertiesTask.cs | 33 +- .../GenerateGitPropertiesCacheTask.cs | 200 ++-------- .../src/GitProperties.Build/GitConfig.cs | 12 + .../GitProperties.Build/GitOutputParser.cs | 135 +++++++ .../src/GitProperties.Build/TagDescription.cs | 14 + .../FallbackFileUsedWhenNoGitAvailableTest.cs | 3 +- .../GitFileWarnsByDefaultTest.cs | 2 +- .../GitPropertiesTestWorkspace.cs | 343 ++---------------- .../MultiProjectSharesCacheTest.cs | 4 +- ...ctSharesCacheAcrossTargetFrameworksTest.cs | 2 +- .../NoCommitsWarnsByDefaultTest.cs | 2 +- .../NoGitInfoWhenEnableWarningsFalseTest.cs | 2 +- .../NoGitWarnsByDefaultTest.cs | 2 +- .../NonAsciiCommitDataRendersCorrectlyTest.cs | 2 +- ...kageReferenceGeneratesGitPropertiesTest.cs | 6 +- ...lowCloneInfoWhenEnableWarningsFalseTest.cs | 2 +- ...ShallowCloneLeavesCommitCountsEmptyTest.cs | 2 +- ...erDetectedConsumingPackageReferenceTest.cs | 5 +- ...rtiesWhenConsumingPackageReferencedTest.cs | 5 +- ...aultOverrideDetectsCustomPackageIdsTest.cs | 6 +- ...errideDoesNotMatchPackageIdAsPrefixTest.cs | 6 +- ...alPropertySkipsGenerationGracefullyTest.cs | 5 +- ...tionWhenNoConsumingPackageReferenceTest.cs | 4 +- .../SyntheticGitRepositoryBuilder.cs | 114 ++++++ .../TestProjectWriter.cs | 252 +++++++++++++ ...henSimulatedPushServerPublishUsesItTest.cs | 3 +- 26 files changed, 632 insertions(+), 534 deletions(-) create mode 100644 src/Management/src/GitProperties.Build/GitConfig.cs create mode 100644 src/Management/src/GitProperties.Build/GitOutputParser.cs create mode 100644 src/Management/src/GitProperties.Build/TagDescription.cs create mode 100644 src/Management/test/GitProperties.Build.Test/SyntheticGitRepositoryBuilder.cs create mode 100644 src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs diff --git a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs index a27645e22f..f812015b90 100644 --- a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -80,15 +80,10 @@ public override bool Execute() } bool isDirty = stdout.Length > 0; - List lines; + List lines = []; - try - { - lines = AtomicFile.ReadAllLinesWithRetry(CacheFile).ToList(); - } - catch (Exception exception) + if (!TryRunFileOperation($"read {CacheFile}", () => lines = AtomicFile.ReadAllLinesWithRetry(CacheFile).ToList())) { - Log.LogError($"git.properties: failed to read {CacheFile}:{Environment.NewLine}{exception}"); return false; } @@ -113,13 +108,8 @@ public override bool Execute() #pragma warning restore S6354 lines.Add($"git.build.time={buildTime}"); - try - { - AtomicFile.WriteAtomic(OutputFile, lines); - } - catch (Exception exception) + if (!TryRunFileOperation($"write {OutputFile}", () => AtomicFile.WriteAtomic(OutputFile, lines))) { - Log.LogError($"git.properties: failed to write {OutputFile}:{Environment.NewLine}{exception}"); return false; } @@ -128,16 +118,25 @@ public override bool Execute() return true; } + return TryRunFileOperation($"write fallback file {FallbackFile}", () => AtomicFile.WriteAtomic(FallbackFile, lines)); + } + + /// + /// Runs a file-system operation that must succeed for this task to succeed - the shape shared by every step below that isn't the initial git status + /// check (which has its own, dual failure modes - see ). Logs a build error and returns false if + /// throws. + /// + private bool TryRunFileOperation(string description, Action action) + { try { - AtomicFile.WriteAtomic(FallbackFile, lines); + action(); + return true; } catch (Exception exception) { - Log.LogError($"git.properties: failed to write fallback file {FallbackFile}:{Environment.NewLine}{exception}"); + Log.LogError($"git.properties: failed to {description}:{Environment.NewLine}{exception}"); return false; } - - return true; } } diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index ea5eb40d67..7c32fe5029 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -2,8 +2,6 @@ // 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; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; @@ -43,12 +41,6 @@ public sealed class GenerateGitPropertiesCacheTask : Task /// private static readonly TimeSpan CacheLockTimeout = TimeSpan.FromSeconds(10); - /// - /// 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)); - /// /// The field separator ("%x1f", ASCII Unit Separator) used in 's own "--pretty=format:" string - a single-element /// array only because requires one, not because there's more than one separator. @@ -163,7 +155,7 @@ private GitVersionStatus CheckGitVersion() return GitVersionStatus.Incompatible; } - Version? installedVersion = ParseGitVersion(output); + Version? installedVersion = GitOutputParser.ParseGitVersion(output); if (installedVersion == null) { @@ -213,28 +205,6 @@ private GitVersionStatus CheckGitVersion() return output; } - /// - /// 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. Pure string - /// parsing, no I/O and no MSBuild logging - deliberately kept separate from so this part alone could be unit-tested without - /// a real git process, if this project ever adds that kind of test. - /// - private 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); - int build = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0; - - return new Version(major, minor, build); - } - /// /// Forgivable checks: the repository must be a usable work tree, and it must have at least one commit - git's own runnability and version were already /// checked in , before this ever runs. Returns null when either check fails - the anomaly is already reported via @@ -313,11 +283,8 @@ private bool TryGenerateAndWriteCache(string commitId) Log.LogMessage("git.properties: generating shared cache at '{0}'.", CacheFile); - int exitCode = RunGit("rev-parse --is-shallow-repository", out string stdout, out string stderr); - - if (exitCode != 0) + if (!TryRunGit("rev-parse --is-shallow-repository", "determine shallow-clone status", out string stdout)) { - Log.LogError("git.properties: failed to determine shallow-clone status: {0}", stderr); return false; } @@ -405,12 +372,9 @@ private bool WasCacheRewrittenWhileWaitingForLock(DateTime? writeTimeBeforeLock) private CommitLogEntry? GetLatestCommitLogEntry() { - int exitCode = RunGit($"log -1 --abbrev={CommitIdAbbrevLength} --pretty=format:%h%x1f%an%x1f%ae%x1f%cI%x1f%s%x1f%B", out string stdout, - out string stderr); - - if (exitCode != 0) + if (!TryRunGit($"log -1 --abbrev={CommitIdAbbrevLength} --pretty=format:%h%x1f%an%x1f%ae%x1f%cI%x1f%s%x1f%B", "read commit metadata", + out string stdout)) { - Log.LogError("git.properties: failed to read commit metadata: {0}", stderr); return null; } @@ -422,52 +386,27 @@ private bool WasCacheRewrittenWhileWaitingForLock(DateTime? writeTimeBeforeLock) } /// - /// Parses the single "describe --tags --long --always" call 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). Failure here is not fatal - it degrades to empty/fallback - /// values, same as "no tags exist". + /// Runs "describe --tags --long --always" and hands its output to . Failure here is not fatal - it + /// degrades to empty/fallback values, same as "no tags exist". /// private TagDescription DescribeClosestTag(bool isShallow) { int exitCode = RunGit("describe --tags --long --always", out string stdout, out _); - string baseDescribe = string.Empty; - string closestTagName = string.Empty; - string closestTagCommitCount = string.Empty; - - if (exitCode == 0 && !string.IsNullOrEmpty(stdout)) - { - int lastDashIndex = stdout.LastIndexOf('-'); - int secondLastDash = lastDashIndex >= 0 ? stdout.LastIndexOf('-', lastDashIndex - 1) : -1; - bool hasTagPrefix = lastDashIndex >= 0 && secondLastDash >= 0 && stdout.Substring(lastDashIndex + 1).StartsWith("g", StringComparison.Ordinal); - - if (!hasTagPrefix) - { - // No tags reachable at all - "--always" fallback is a bare abbreviated SHA. - baseDescribe = stdout; - } - else - { - closestTagName = stdout.Substring(0, secondLastDash); - closestTagCommitCount = stdout.Substring(secondLastDash + 1, lastDashIndex - secondLastDash - 1); - baseDescribe = closestTagCommitCount == "0" ? closestTagName : $"{closestTagName}-{closestTagCommitCount}"; - } - } + TagDescription description = exitCode == 0 ? GitOutputParser.ParseTagDescribe(stdout) : TagDescription.Empty; if (isShallow) { // Ancestry walk is truncated on a shallow clone - a "count" here would be silently wrong. - closestTagCommitCount = string.Empty; + description = new TagDescription(description.BaseDescribe, description.ClosestTagName, string.Empty); } - return new TagDescription(baseDescribe, closestTagName, closestTagCommitCount); + return description; } private TagsAndCommitCount? ReadTagsAndTotalCommitCount(bool isShallow) { - int exitCode = RunGit("tag --points-at HEAD", out string stdout, out string stderr); - - if (exitCode != 0) + if (!TryRunGit("tag --points-at HEAD", "list tags", out string stdout)) { - Log.LogError("git.properties: failed to list tags: {0}", stderr); return null; } @@ -477,11 +416,8 @@ private TagDescription DescribeClosestTag(bool isShallow) if (!isShallow) { - exitCode = RunGit("rev-list --count HEAD", out stdout, out stderr); - - if (exitCode != 0) + if (!TryRunGit("rev-list --count HEAD", "count commits", out stdout)) { - Log.LogError("git.properties: failed to count commits: {0}", stderr); return null; } @@ -493,45 +429,12 @@ private TagDescription DescribeClosestTag(bool isShallow) private GitConfig? ReadConfig() { - int exitCode = RunGit("config --list", out string stdout, out string stderr); - - if (exitCode != 0) + if (!TryRunGit("config --list", "read git config", out string stdout)) { - Log.LogError("git.properties: failed to read git config: {0}", stderr); return null; } - string userName = string.Empty; - string userEmail = string.Empty; - string remoteUrl = string.Empty; - - foreach (string line in stdout.Split('\n')) - { - int equalsIndex = line.IndexOf('='); - - if (equalsIndex < 0) - { - continue; - } - - 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; - } - } - - return new GitConfig(userName, userEmail, StripUserInfo(remoteUrl)); + return GitOutputParser.ParseConfig(stdout); } /// @@ -569,6 +472,25 @@ private int RunGit(string arguments, out string stdout, out string stderr) return GitProcessRunner.Run(GitExecutable, RepositoryRoot, arguments, out stdout, out stderr); } + /// + /// Runs a git command whose failure is unexpected and fatal for cache generation - the shape shared by every call in + /// that isn't allowed to degrade gracefully (unlike, say, or + /// , which fall back to empty values instead). Logs a build error and returns false on a non-zero exit code; + /// is only meaningful when this returns true. + /// + private bool TryRunGit(string arguments, string description, out string stdout) + { + int exitCode = RunGit(arguments, out stdout, out string stderr); + + if (exitCode != 0) + { + Log.LogError("git.properties: failed to {0}: {1}", description, stderr); + return false; + } + + return true; + } + /// /// Reports a forgivable anomaly - either a full skip (GITPROPS001/003/004/005, where the caller has already decided to skip generation and return true) /// or a degraded-but-successful outcome (GITPROPS006, where generation still proceeds) - as a warning when is true, or a @@ -594,36 +516,6 @@ private void ReportDiagnostic(string code, string message) } } - private static string StripUserInfo(string url) - { - if (string.IsNullOrEmpty(url)) - { - return 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") - nothing to strip. - } - - return url; - } - /// /// Whether the installed git is new enough to use, as determined by . /// @@ -641,10 +533,10 @@ private enum GitVersionStatus Incompatible, /// - /// Git ran, but its "--version" output couldn't be parsed at all - a bug in 's own regex, or an unrecognized version - /// string shape, not a routine/anticipated condition like "compatible" or "incompatible". Unlike those two, there's no safe conclusion to draw here - we - /// genuinely don't know whether the installed git is usable - so this is reported as a hard error via Log.LogError and, unlike every other skip in this - /// class, stops the build rather than letting a stale cache (if one exists from an earlier run) get used regardless. + /// Git ran, but its "--version" output couldn't be parsed at all - a bug in 's own regex, or an + /// unrecognized version string shape, not a routine/anticipated condition like "compatible" or "incompatible". Unlike those two, there's no safe + /// conclusion to draw here - we genuinely don't know whether the installed git is usable - so this is reported as a hard error via Log.LogError and, + /// unlike every other skip in this class, stops the build rather than letting a stale cache (if one exists from an earlier run) get used regardless. /// Unknown } @@ -667,16 +559,6 @@ private sealed class CommitLogEntry(string abbrevId, string authorName, string a public string FullMessage { get; } = fullMessage; } - /// - /// The fields derived from a single "describe --tags --long --always" call - see . - /// - private sealed class TagDescription(string baseDescribe, string closestTagName, string closestTagCommitCount) - { - public string BaseDescribe { get; } = baseDescribe; - public string ClosestTagName { get; } = closestTagName; - public string ClosestTagCommitCount { get; } = closestTagCommitCount; - } - /// /// The fields read from "tag --points-at HEAD" and (unless shallow) "rev-list --count HEAD" - see . /// @@ -685,14 +567,4 @@ private sealed class TagsAndCommitCount(string tags, string totalCommitCount) public string Tags { get; } = tags; public string TotalCommitCount { get; } = totalCommitCount; } - - /// - /// The fields read from a single "config --list" call - see . - /// - private 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/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/GitOutputParser.cs b/src/Management/src/GitProperties.Build/GitOutputParser.cs new file mode 100644 index 0000000000..8b0729f981 --- /dev/null +++ b/src/Management/src/GitProperties.Build/GitOutputParser.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 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); + int build = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0; + + return new Version(major, minor, build); + } + + /// + /// Parses "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 - "--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 "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") - nothing to strip. + } + } + + return url; + } +} 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/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs index 5628796659..435e99b524 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs @@ -22,7 +22,8 @@ public async Task FallbackFile_UsedWhenNoGitAvailable() Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); fallbackProperties["git.dirty"].Should().Be("false", "the gitignored fallback file must not make its own producing build see the tree as dirty."); - string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); + string destinationDirectory = Path.Combine(Workspace.RootDirectory, "pushed"); + string pushedRoot = SyntheticGitRepositoryBuilder.SimulateSourcePush(repository, destinationDirectory); string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); diff --git a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs index 92b1d0f625..c9cac43866 100644 --- a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs @@ -11,7 +11,7 @@ public async Task GitFile_WarnsByDefault() { string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); Directory.CreateDirectory(projectDirectory); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(projectDirectory); // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk // (which starts at TestApp, the project actually being built) to find it - i.e. at // projectDirectory itself. diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index 210db14d3f..9346385358 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -7,9 +7,16 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// -/// An isolated temporary directory tree for a single test, cleaned up on Dispose. Deliberately avoids "gitprop" in its own name (in any casing) so a -/// test's workspace path can never accidentally satisfy an Assert.Contains/DoesNotContain check against a GITPROPS0xx diagnostic code in build output, -/// which routinely echoes back the working directory path. +/// An isolated temporary directory tree for a single test, cleaned up on Dispose - and the entry point every test uses to set up its own synthetic +/// project/repository layout. Owns only its own lifecycle plus the couple of methods that need its own ; the actual git-repo +/// mechanics live in and project/package file writing lives in - both are +/// exposed here as thin forwarding methods purely so every test can keep calling through +/// +/// Workspace +/// +/// / without needing to know which of the three classes actually implements a given piece. Deliberately avoids +/// "gitprop" in its own name (in any casing) so a test's workspace path can never accidentally satisfy an Assert.Contains/DoesNotContain check against a +/// GITPROPS0xx diagnostic code in build output, which routinely echoes back the working directory path. /// /// /// Constructed via rather than a public constructor: resolving the physical (symlink-free) root directory on macOS needs a @@ -19,17 +26,10 @@ internal sealed class GitPropertiesTestWorkspace : IDisposable { /// /// The project name every dev-loop consumer test writes its own copy of Steeltoe.Management.GitProperties.Build against (see - /// ) - shared so callers never retype it. + /// ) - shared so callers never retype it. /// public const string TestAppProjectName = "TestApp"; - private static readonly HashSet SimulatedPushExcludedDirectoryNames = new(StringComparer.OrdinalIgnoreCase) - { - ".git", - "bin", - "obj" - }; - public string RootDirectory { get; } private GitPropertiesTestWorkspace(string rootDirectory) @@ -100,154 +100,10 @@ private static void ClearReadOnlyAttributes(DirectoryInfo directory) } /// - /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source (csproj + all .cs files + the targets file + SourceCheckout.txt) into - /// "src\Management\src\GitProperties.Build" under the given repository root - the exact relative depth its own csproj's "shared.props" import expects. - /// Copying the marker file matters: without it, $(GitPropertiesTaskHost) in Steeltoe.Management.GitProperties.Build.targets would (correctly, but - /// unhelpfully for these tests) detect "packaged" consumption instead of the dev loop this is meant to simulate, silently skipping the TaskHostFactory - /// path every other test here relies on. - /// - private static async Task CopyGitPropertiesBuildSourceAsync(string repoRootDestination) - { - string destination = Path.Combine(repoRootDestination, TestPaths.GitPropertiesBuildRelativePath); - Directory.CreateDirectory(Path.Combine(destination, "build")); - - string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); - string targetsFile = await TestPaths.GetTargetsFileAsync(); - string markerFile = await TestPaths.GetSourceCheckoutMarkerFileAsync(); - string buildDirectory = await TestPaths.GetGitPropertiesBuildDirectoryAsync(); - - File.Copy(projectFile, Path.Combine(destination, Path.GetFileName(projectFile)), true); - File.Copy(targetsFile, Path.Combine(destination, "build", Path.GetFileName(targetsFile)), true); - File.Copy(markerFile, Path.Combine(destination, Path.GetFileName(markerFile)), true); - - foreach (string sourceFile in Directory.GetFiles(buildDirectory, "*.cs")) - { - File.Copy(sourceFile, Path.Combine(destination, Path.GetFileName(sourceFile)), true); - } - } - - /// - /// Copies the handful of root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own shared.props import - /// chain needs (versioning, packaging defaults, analyzers), so a synthetic test repo - which has none of Steeltoe's other real projects - still - /// evaluates the same properties a real build inside the repository would. - /// - private static async Task CopySharedBuildInfrastructureAsync(string repoRootDestination) - { - Directory.CreateDirectory(repoRootDestination); - string repositoryRoot = await TestPaths.GetRepositoryRootAsync(); - - foreach (string fileName in TestPaths.SharedBuildInfrastructureFiles) - { - File.Copy(Path.Combine(repositoryRoot, fileName), Path.Combine(repoRootDestination, fileName), true); - } - } - - /// - /// Writes a minimal exe project named under the given repository root, referencing - /// Steeltoe.Management.GitProperties.Build the way a same-solution project (dev loop) would: a ReferenceOutputAssembly="false" ProjectReference (just to - /// order the build) plus an explicit Import of the .targets file - the NuGet "build\{PackageId}.targets" auto-import convention only kicks in for a real - /// PackageReference consumer (see NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties for that). Single-targeted at - /// unless is given, in which case the project multi-targets that - /// semicolon-separated list instead (see ). - /// - /// - /// The directory to write the project under - typically a repository root returned by . - /// - /// - /// The project's name - also used as its directory and file name. - /// - /// - /// A semicolon-separated list of target frameworks to multi-target, or null for a single-targeted project at - /// . - /// - /// - /// Emits an explicit $(GenerateGitProperties) override when non-null - true for almost every test here, since they exist to test generation itself, not - /// the smart default that decides whether it runs at all. Pass null (letting the smart default apply) only for tests that specifically cover - /// DetectConsumingPackageReferenceTask/$(GitPropertiesConsumingPackageIds) - see . - /// - /// - /// Extra raw XML inserted into the same <ItemGroup> as the ProjectReference above - currently only used to add a second, normal (not - /// ReferenceOutputAssembly="false") ProjectReference to a stand-in, so it actually participates in - /// restore and shows up in this project's own project.assets.json (see DetectConsumingPackageReferenceTask's remarks for why that distinction matters). - /// - public static async Task WriteAppProjectAsync(string repoRootDestination, string projectName, string? targetFrameworks = null, - bool? generateGitProperties = true, string? extraItemGroupContent = null) - { - string appDirectory = Path.Combine(repoRootDestination, projectName); - Directory.CreateDirectory(appDirectory); - - string targetFrameworkElement = targetFrameworks == null - ? $"{TestPaths.TestAppTargetFramework}" - : $"{targetFrameworks}"; - - string generateGitPropertiesElement = string.Empty; - - if (generateGitProperties != null) - { - string generateGitPropertiesValue = generateGitProperties.Value ? "true" : "false"; - generateGitPropertiesElement = $"{generateGitPropertiesValue}"; - } - - string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); - string targetsFile = await TestPaths.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"), """ - Console.WriteLine("Hello, World!"); - """, TestContext.Current.CancellationToken); - - return appDirectory; - } - - /// - /// Writes a minimal, do-nothing class library project - used only by the smart-default detection tests as a stand-in for a real dependency (e.g. - /// Steeltoe.Management.Endpoint itself, or some other actuator-registering package) that a test app references NORMALLY (see - /// 's caller), so it actually participates in restore and shows up in the referencing project's own - /// project.assets.json - unlike WriteAppProjectAsync's own ReferenceOutputAssembly="false" reference to Steeltoe.Management.GitProperties.Build, which - /// was verified (empirically, against a real "dotnet restore") to be excluded from the resolved dependency graph entirely. - /// - public static async Task WriteDummyDependencyProjectAsync(string repoRootDestination, string projectName) - { - string projectDirectory = Path.Combine(repoRootDestination, projectName); - Directory.CreateDirectory(projectDirectory); - - await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), $""" - - - {TestPaths.TestAppTargetFramework} - - - """, TestContext.Current.CancellationToken); - - return projectDirectory; - } - - /// - /// A brand-new, synthetic git repo with a controlled, minimal history (`git init` plus a handful of manufactured commits) - deliberately never a clone - /// of this (large, real) repository, so the suite stays fast. Returns the repository root (not the TestApp directory - callers combine "TestApp" - /// themselves, mirroring how multi-project tests place additional sibling projects at the same root). + /// A brand-new, synthetic git repo with a controlled, minimal history (`git init` plus a handful of manufactured commits, via + /// ) - deliberately never a clone of this (large, real) repository, so the suite stays fast. + /// Returns the repository root (not the TestApp directory - callers combine "TestApp" themselves, mirroring how multi-project tests place additional + /// sibling projects at the same root). /// /// /// The directory to initialize the repository in. @@ -257,174 +113,21 @@ await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.cspr /// /// /// Whether to also list "git.properties" in the repository's .gitignore, modeling the setup a real consumer of $(GitPropertiesWriteToProjectDirectory) - /// must follow - see SimulateSourcePush for the complementary "not .cfignore'd" half of that same guidance. Defaults to false so most tests (which never - /// write a fallback file into the project directory at all) aren't given a gitignore entry they don't exercise - only pass true for tests that - /// specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that accidentally wrote one in a test that doesn't - /// expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being silently absorbed by a blanket ignore rule. + /// must follow - see for the complementary "not .cfignore'd" half of that same guidance. + /// Defaults to false so most tests (which never write a fallback file into the project directory at all) aren't given a gitignore entry they don't + /// exercise - only pass true for tests that specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that + /// accidentally wrote one in a test that doesn't expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being + /// silently absorbed by a blanket ignore rule. /// public async Task CreateSyntheticRepoAsync(string destination, int commitCount, bool gitignoreFallbackFile = false) { - Directory.CreateDirectory(destination); - await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); - await ProcessRunner.RunGitAsync(destination, "config", "user.name", "Test User"); - await ProcessRunner.RunGitAsync(destination, "config", "user.email", "test@example.com"); - - // Without this, dotnet build's own obj/bin output is untracked and git status correctly - // (but unhelpfully, for these tests) reports the tree as dirty - real projects always - // gitignore build output, same as this repo does. - string gitignoreContent = gitignoreFallbackFile - ? """ - 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 ProcessRunner.RunGitAsync(destination, "add", "-A"); - await ProcessRunner.RunGitAsync(destination, "commit", "--quiet", "-m", $"Commit {commitNumber}"); - } - - await CopyCurrentProjectFilesAsync(destination); + await SyntheticGitRepositoryBuilder.InitializeAsync(destination, commitCount, gitignoreFallbackFile); + await TestProjectWriter.CopyCurrentProjectFilesAsync(destination); // Commit the project files too, so the synthetic repo starts clean (git.dirty=false) // unless a test deliberately makes a further change - otherwise every synthetic repo would // show git.dirty=true purely because of these untracked-but-just-added files. - await ProcessRunner.RunGitAsync(destination, "add", "-A"); - await ProcessRunner.RunGitAsync(destination, "commit", "--quiet", "-m", "Add project files"); + await SyntheticGitRepositoryBuilder.CommitAllAsync(destination, "Add project files"); return destination; } - - /// - /// Copies a directory tree to a brand-new location with no ".git" anywhere in its ancestry, simulating what actually reaches a running app when deployed - /// via `cf push` using the dotnet_core_buildpack from source: ".git" is excluded from the pushed tree unconditionally by `cf push` itself (a CLI-level - /// default, independent of ".cfignore" and not something the buildpack has any special handling for - verified against both tools' source), which is - /// exactly why live git.properties generation can never run server-side for that scenario. "bin"/"obj" are also excluded, mirroring the ".cfignore" - /// hygiene real projects need anyway (both to avoid pushing stale local build output, and because reusing another location's "obj" as-is would confuse - /// MSBuild's own incremental state, which embeds absolute paths). Anything else - including an already-generated fallback "git.properties" sitting next - /// to the ".csproj" - is copied as-is, exactly as it would ride along in the real push payload. - /// - public static string SimulateSourcePush(string sourceDirectory, string destinationDirectory) - { - CopyDirectoryExcluding(new DirectoryInfo(sourceDirectory), destinationDirectory, SimulatedPushExcludedDirectoryNames); - return destinationDirectory; - } - - 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); - } - } - - /// - /// Copies the shared Steeltoe build infrastructure plus the CURRENT Steeltoe.Management.GitProperties.Build source into the given directory (which - /// becomes the "repository root" for relative-path purposes, whether or not it's actually a git repository), then writes a TestApp project referencing - /// it. Returns the TestApp directory. - /// - public async Task CopyCurrentProjectFilesAsync(string destination) - { - await CopySharedBuildInfrastructureAsync(destination); - await CopyGitPropertiesBuildSourceAsync(destination); - return await WriteAppProjectAsync(destination, TestAppProjectName); - } - - /// - /// Packs a FRESH COPY of the current Steeltoe.Management.GitProperties.Build source into a local folder feed, so the PackageReference-based consumption - /// test exercises the exact same source as every other test here - not a stale .nupkg left behind by an earlier `dotnet build` of the real repo. A plain - /// filesystem directory is a valid NuGet feed on its own; no server involved. Returns the feed directory: a plain `dotnet build` (not `dotnet pack`) - /// because GeneratePackageOnBuild already produces the .nupkg as a side effect of a Release build - `dotnet pack` alone does NOT build first here - /// (IncludeBuildOutput=false makes NuGet's Pack target skip its usual dependency on Build), so it fails with NU5019 ("file not found") against the DLL - /// our own <None Include="$(TargetPath)"> pack item expects to already exist. - /// - public async Task PackGitPropertiesBuildToFeedAsync() - { - string packSourceDirectory = Path.Combine(RootDirectory, "pack-source"); - await CopySharedBuildInfrastructureAsync(packSourceDirectory); - await CopyGitPropertiesBuildSourceAsync(packSourceDirectory); - - string projectDirectory = Path.Combine(packSourceDirectory, TestPaths.GitPropertiesBuildRelativePath); - string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); - await ProcessRunner.RunDotnetAsync(projectDirectory, "build", Path.GetFileName(projectFile), "-c", "Release"); - - string targetFramework = await TestPaths.GetGitPropertiesBuildTargetFrameworkAsync(); - return Path.Combine(projectDirectory, "bin", "tasks", targetFramework); - } - - /// - /// A minimal nuget.config that clears every inherited package source (machine-wide, user-wide, ambient) down to just the given local feed. Without < - /// clear/>, restore would still see nuget.org and friends - harmless here since nothing else is needed, but clearing makes the test fully - /// offline-capable and guarantees it's really our local build being consumed, not a same-named/versioned package resolved from somewhere else. - /// - public static async Task WriteIsolatedNuGetConfigAsync(string filePath, string feedDirectory) - { - string content = $""" - - - - - - - - """; - - await File.WriteAllTextAsync(filePath, content, TestContext.Current.CancellationToken); - } - - /// - /// A bare console app that consumes Steeltoe.Management.GitProperties.Build the way a real, external user would - via <PackageReference> against a - /// built .nupkg - as opposed to every other test here, which uses a ProjectReference/Import straight against source. No explicit <Import> of the - /// .targets file: that's the whole point of the "build\{PackageId}.targets" NuGet auto-import convention this package relies on, and this is the only - /// test that actually exercises it end-to-end. - /// - public static async Task CreatePackageConsumerProjectAsync(string projectDirectory, string packageVersion) - { - Directory.CreateDirectory(projectDirectory); - - string projectContent = $""" - - - Exe - {TestPaths.TestAppTargetFramework} - enable - - true - - - - - - - """; - - await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent, TestContext.Current.CancellationToken); - - await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), """ - Console.WriteLine("Hello, World!"); - """, TestContext.Current.CancellationToken); - } } diff --git a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs index f8664ac838..8a978954c2 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs @@ -15,8 +15,8 @@ public async Task MultiProject_SharesCache() // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepo // already placed TestApp there; reuse that exact relative layout for ProjectA/ProjectB by placing them // at the repo root too, siblings of TestApp and "src"). - await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectA"); - await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "ProjectB"); + await TestProjectWriter.WriteAppProjectAsync(repository, "ProjectA"); + await TestProjectWriter.WriteAppProjectAsync(repository, "ProjectB"); string projectA = Path.Combine(repository, "ProjectA"); string projectB = Path.Combine(repository, "ProjectB"); diff --git a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs index 0a30abb4d6..b28e51232d 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs @@ -18,7 +18,7 @@ public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); await ProcessRunner.RunDotnetAsync(testApp, "build"); diff --git a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs index 5d9214ad3a..5bad00309c 100644 --- a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs @@ -16,7 +16,7 @@ public async Task NoCommits_WarnsByDefault() string repository = Path.Combine(Workspace.RootDirectory, "repo"); Directory.CreateDirectory(repository); await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(repository); string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertWarned(defaultResult, "GITPROPS005"); diff --git a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs index 5739cffdba..4be4b3adbd 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs @@ -11,7 +11,7 @@ public async Task NoGit_InfoWhenEnableWarningsFalse() { string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); Directory.CreateDirectory(projectDirectory); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(projectDirectory); string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); diff --git a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs index 7c76915b70..6d0e5115fb 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs @@ -11,7 +11,7 @@ public async Task NoGit_WarnsByDefault() { string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); Directory.CreateDirectory(projectDirectory); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(projectDirectory); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(projectDirectory); string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); AssertWarned(result, "GITPROPS001"); diff --git a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs index 272639553b..6eccf7720e 100644 --- a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -31,7 +31,7 @@ public async Task NonAscii_CommitDataRendersCorrectly() await ProcessRunner.RunGitAsync(repository, "add", "-A"); await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitSubject, "-m", commitBody); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(repository); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(repository); await ProcessRunner.RunDotnetAsync(testApp, "build"); diff --git a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs index 6e90bdf6db..3c5566a418 100644 --- a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -22,7 +22,7 @@ public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitPropertie { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); + string feedDirectory = await TestProjectWriter.PackGitPropertiesBuildToFeedAsync(Workspace.RootDirectory); string packageId = await TestPaths.GetPackageIdAsync(); string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); @@ -34,8 +34,8 @@ public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitPropertie string packageVersion = versionMatch.Groups[1].Value; string consumerDirectory = Path.Combine(repository, "Consumer"); - await GitPropertiesTestWorkspace.CreatePackageConsumerProjectAsync(consumerDirectory, packageVersion); - await GitPropertiesTestWorkspace.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); + await TestProjectWriter.CreatePackageConsumerProjectAsync(consumerDirectory, packageVersion); + await TestProjectWriter.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); string isolatedPackagesPath = Path.Combine(Workspace.RootDirectory, "isolated-packages"); string result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs index 1e2a7198c6..836c1b1cd2 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs @@ -19,7 +19,7 @@ public async Task ShallowClone_InfoWhenEnableWarningsFalse() string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(shallow); string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs index 8c47d92527..8f4c36f8da 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -20,7 +20,7 @@ public async Task ShallowClone_LeavesCommitCountsEmpty() string isShallowRepository = await ProcessRunner.GetGitOutputAsync(shallow, "rev-parse", "--is-shallow-repository"); isShallowRepository.Should().Be("true"); - string testApp = await Workspace.CopyCurrentProjectFilesAsync(shallow); + string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(shallow); string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); result.Should().NotContain("GITPROPS001"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs index be590a1a6c..f5820bf4d8 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs @@ -17,10 +17,9 @@ public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageRef { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: $""""""); await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs index 3d53022e18..22dc9e69e2 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -17,10 +17,9 @@ public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageRefere { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: $""""""); await ProcessRunner.RunDotnetAsync(testApp, "build"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs index af317469b2..747a364a8e 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs @@ -15,10 +15,10 @@ public async Task SmartDefault_Override_DetectsCustomPackageIds() { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string customPackageId = "Contoso.Actuators"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, customPackageId); + await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, customPackageId); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, extraItemGroupContent: $""""""); + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs index 3b2314ce1e..130c61ee9c 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs @@ -16,10 +16,10 @@ public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string longerPackageId = "Some2"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, longerPackageId); + await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, longerPackageId); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, extraItemGroupContent: $""""""); + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: $""""""); await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); AssertNoGitPropertiesGenerated(testApp); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs index b08c713873..f772f8116a 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs @@ -18,10 +18,9 @@ public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGe { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await GitPropertiesTestWorkspace.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); - string testApp = await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, - generateGitProperties: null, + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: $""""""); await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs index 3efc9ffb8e..c8c87b0cf9 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -16,9 +16,7 @@ public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTe public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() { string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - - string testApp = - await GitPropertiesTestWorkspace.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); + string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). diff --git a/src/Management/test/GitProperties.Build.Test/SyntheticGitRepositoryBuilder.cs b/src/Management/test/GitProperties.Build.Test/SyntheticGitRepositoryBuilder.cs new file mode 100644 index 0000000000..a770ab2303 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/SyntheticGitRepositoryBuilder.cs @@ -0,0 +1,114 @@ +// 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; + +/// +/// Builds synthetic git repositories with a controlled, minimal commit history for tests, and simulates the one deployment step (`cf push`) that strips +/// ".git" from what a running app actually sees on disk. Deliberately never operates on a clone of this (large, real) repository, so the suite stays +/// fast - see for the workspace-level entry point that also copies in the project +/// files under test. +/// +internal static class SyntheticGitRepositoryBuilder +{ + private static readonly HashSet SimulatedPushExcludedDirectoryNames = new(StringComparer.OrdinalIgnoreCase) + { + ".git", + "bin", + "obj" + }; + + /// + /// `git init` plus a handful of manufactured commits under . Deliberately stops short of committing anything beyond those + /// manufactured files - see for the project-files-copy and final commit step this + /// leaves to its caller. + /// + /// + /// The directory to initialize the repository in. + /// + /// + /// The number of manufactured commits to create. + /// + /// + /// Whether to also list "git.properties" in the repository's .gitignore - see for the + /// full explanation. + /// + public static async Task InitializeAsync(string destination, int commitCount, bool gitignoreFallbackFile) + { + Directory.CreateDirectory(destination); + await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); + await ProcessRunner.RunGitAsync(destination, "config", "user.name", "Test User"); + await ProcessRunner.RunGitAsync(destination, "config", "user.email", "test@example.com"); + + // Without this, dotnet build's own obj/bin output is untracked and git status correctly + // (but unhelpfully, for these tests) reports the tree as dirty - real projects always + // gitignore build output, same as this repo does. + string gitignoreContent = gitignoreFallbackFile + ? """ + 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 ProcessRunner.RunGitAsync(destination, "add", "-A"); + await ProcessRunner.RunGitAsync(destination, "commit", "--quiet", "-m", $"Commit {commitNumber}"); + } + } + + /// + /// A small "git add -A / git commit" primitive - used by to commit the project files + /// it copies in after runs. + /// + public static async Task CommitAllAsync(string repositoryDirectory, string message) + { + await ProcessRunner.RunGitAsync(repositoryDirectory, "add", "-A"); + await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", message); + } + + /// + /// Copies a directory tree to a brand-new location with no ".git" anywhere in its ancestry, simulating what actually reaches a running app when deployed + /// via `cf push` using the dotnet_core_buildpack from source: ".git" is excluded from the pushed tree unconditionally by `cf push` itself (a CLI-level + /// default, independent of ".cfignore" and not something the buildpack has any special handling for - verified against both tools' source), which is + /// exactly why live git.properties generation can never run server-side for that scenario. "bin"/"obj" are also excluded, mirroring the ".cfignore" + /// hygiene real projects need anyway (both to avoid pushing stale local build output, and because reusing another location's "obj" as-is would confuse + /// MSBuild's own incremental state, which embeds absolute paths). Anything else - including an already-generated fallback "git.properties" sitting next + /// to the ".csproj" - is copied as-is, exactly as it would ride along in the real push payload. + /// + public static string SimulateSourcePush(string sourceDirectory, string destinationDirectory) + { + CopyDirectoryExcluding(new DirectoryInfo(sourceDirectory), destinationDirectory, SimulatedPushExcludedDirectoryNames); + return destinationDirectory; + } + + 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/TestProjectWriter.cs b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs new file mode 100644 index 0000000000..dc16590717 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs @@ -0,0 +1,252 @@ +// 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; + +/// +/// Writes the synthetic project files (.csproj/Program.cs/nuget.config) that dev-loop and PackageReference-consumer tests build against, and copies the +/// CURRENT Steeltoe.Management.GitProperties.Build source - plus the handful of root-level Steeltoe build infrastructure files its own "shared.props" +/// import chain needs - into a synthetic test repository, so every test exercises whatever is on disk right now, never a stale git-tracked copy. +/// +internal static class TestProjectWriter +{ + /// + /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source (csproj + all .cs files + the targets file + SourceCheckout.txt) into + /// "src\Management\src\GitProperties.Build" under the given repository root - the exact relative depth its own csproj's "shared.props" import expects. + /// Copying the marker file matters: without it, $(GitPropertiesTaskHost) in Steeltoe.Management.GitProperties.Build.targets would (correctly, but + /// unhelpfully for these tests) detect "packaged" consumption instead of the dev loop this is meant to simulate, silently skipping the TaskHostFactory + /// path every other test here relies on. + /// + private static async Task CopyGitPropertiesBuildSourceAsync(string repoRootDestination) + { + string destination = Path.Combine(repoRootDestination, TestPaths.GitPropertiesBuildRelativePath); + Directory.CreateDirectory(Path.Combine(destination, "build")); + + string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); + string targetsFile = await TestPaths.GetTargetsFileAsync(); + string markerFile = await TestPaths.GetSourceCheckoutMarkerFileAsync(); + string buildDirectory = await TestPaths.GetGitPropertiesBuildDirectoryAsync(); + + File.Copy(projectFile, Path.Combine(destination, Path.GetFileName(projectFile)), true); + File.Copy(targetsFile, Path.Combine(destination, "build", Path.GetFileName(targetsFile)), true); + File.Copy(markerFile, Path.Combine(destination, Path.GetFileName(markerFile)), true); + + foreach (string sourceFile in Directory.GetFiles(buildDirectory, "*.cs")) + { + File.Copy(sourceFile, Path.Combine(destination, Path.GetFileName(sourceFile)), true); + } + } + + /// + /// Copies the handful of root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own shared.props import + /// chain needs (versioning, packaging defaults, analyzers), so a synthetic test repo - which has none of Steeltoe's other real projects - still + /// evaluates the same properties a real build inside the repository would. + /// + private static async Task CopySharedBuildInfrastructureAsync(string repoRootDestination) + { + Directory.CreateDirectory(repoRootDestination); + string repositoryRoot = await TestPaths.GetRepositoryRootAsync(); + + foreach (string fileName in TestPaths.SharedBuildInfrastructureFiles) + { + File.Copy(Path.Combine(repositoryRoot, fileName), Path.Combine(repoRootDestination, fileName), true); + } + } + + /// + /// Writes a minimal exe project named under the given repository root, referencing + /// Steeltoe.Management.GitProperties.Build the way a same-solution project (dev loop) would: a ReferenceOutputAssembly="false" ProjectReference (just to + /// order the build) plus an explicit Import of the .targets file - the NuGet "build\{PackageId}.targets" auto-import convention only kicks in for a real + /// PackageReference consumer (see NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties for that). Single-targeted at + /// unless is given, in which case the project multi-targets that + /// semicolon-separated list instead (see ). + /// + /// + /// The directory to write the project under - typically a repository root returned by + /// . + /// + /// + /// The project's name - also used as its directory and file name. + /// + /// + /// A semicolon-separated list of target frameworks to multi-target, or null for a single-targeted project at + /// . + /// + /// + /// Emits an explicit $(GenerateGitProperties) override when non-null - true for almost every test here, since they exist to test generation itself, not + /// the smart default that decides whether it runs at all. Pass null (letting the smart default apply) only for tests that specifically cover + /// DetectConsumingPackageReferenceTask/$(GitPropertiesConsumingPackageIds) - see . + /// + /// + /// Extra raw XML inserted into the same <ItemGroup> as the ProjectReference above - currently only used to add a second, normal (not + /// ReferenceOutputAssembly="false") ProjectReference to a stand-in, so it actually participates in + /// restore and shows up in this project's own project.assets.json (see DetectConsumingPackageReferenceTask's remarks for why that distinction matters). + /// + public static async Task WriteAppProjectAsync(string repoRootDestination, string projectName, string? targetFrameworks = null, + bool? generateGitProperties = true, string? extraItemGroupContent = null) + { + string appDirectory = Path.Combine(repoRootDestination, projectName); + Directory.CreateDirectory(appDirectory); + + string targetFrameworkElement = targetFrameworks == null + ? $"{TestPaths.TestAppTargetFramework}" + : $"{targetFrameworks}"; + + string generateGitPropertiesElement = string.Empty; + + if (generateGitProperties != null) + { + string generateGitPropertiesValue = generateGitProperties.Value ? "true" : "false"; + generateGitPropertiesElement = $"{generateGitPropertiesValue}"; + } + + string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); + string targetsFile = await TestPaths.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"), """ + Console.WriteLine("Hello, World!"); + """, TestContext.Current.CancellationToken); + + return appDirectory; + } + + /// + /// Writes a minimal, do-nothing class library project - used only by the smart-default detection tests as a stand-in for a real dependency (e.g. + /// Steeltoe.Management.Endpoint itself, or some other actuator-registering package) that a test app references NORMALLY (see + /// 's caller), so it actually participates in restore and shows up in the referencing project's own + /// project.assets.json - unlike WriteAppProjectAsync's own ReferenceOutputAssembly="false" reference to Steeltoe.Management.GitProperties.Build, which + /// was verified (empirically, against a real "dotnet restore") to be excluded from the resolved dependency graph entirely. + /// + public static async Task WriteDummyDependencyProjectAsync(string repoRootDestination, string projectName) + { + string projectDirectory = Path.Combine(repoRootDestination, projectName); + Directory.CreateDirectory(projectDirectory); + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), $""" + + + {TestPaths.TestAppTargetFramework} + + + """, TestContext.Current.CancellationToken); + + return projectDirectory; + } + + /// + /// Copies the shared Steeltoe build infrastructure plus the CURRENT Steeltoe.Management.GitProperties.Build source into the given directory (which + /// becomes the "repository root" for relative-path purposes, whether or not it's actually a git repository), then writes a TestApp project referencing + /// it. Returns the TestApp directory. + /// + public static async Task CopyCurrentProjectFilesAsync(string destination) + { + await CopySharedBuildInfrastructureAsync(destination); + await CopyGitPropertiesBuildSourceAsync(destination); + return await WriteAppProjectAsync(destination, GitPropertiesTestWorkspace.TestAppProjectName); + } + + /// + /// Packs a FRESH COPY of the current Steeltoe.Management.GitProperties.Build source into a local folder feed, so the PackageReference-based consumption + /// test exercises the exact same source as every other test here - not a stale .nupkg left behind by an earlier `dotnet build` of the real repo. A plain + /// filesystem directory is a valid NuGet feed on its own; no server involved. Returns the feed directory: a plain `dotnet build` (not `dotnet pack`) + /// because GeneratePackageOnBuild already produces the .nupkg as a side effect of a Release build - `dotnet pack` alone does NOT build first here + /// (IncludeBuildOutput=false makes NuGet's Pack target skip its usual dependency on Build), so it fails with NU5019 ("file not found") against the DLL + /// our own <None Include="$(TargetPath)"> pack item expects to already exist. + /// + /// + /// The calling 's own - the pack source is written + /// under a "pack-source" subdirectory of it, kept separate from anything else the workspace writes. + /// + 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, TestPaths.GitPropertiesBuildRelativePath); + string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); + await ProcessRunner.RunDotnetAsync(projectDirectory, "build", Path.GetFileName(projectFile), "-c", "Release"); + + string targetFramework = await TestPaths.GetGitPropertiesBuildTargetFrameworkAsync(); + return Path.Combine(projectDirectory, "bin", "tasks", targetFramework); + } + + /// + /// A minimal nuget.config that clears every inherited package source (machine-wide, user-wide, ambient) down to just the given local feed. Without < + /// clear/>, restore would still see nuget.org and friends - harmless here since nothing else is needed, but clearing makes the test fully + /// offline-capable and guarantees it's really our local build being consumed, not a same-named/versioned package resolved from somewhere else. + /// + public static async Task WriteIsolatedNuGetConfigAsync(string filePath, string feedDirectory) + { + string content = $""" + + + + + + + + """; + + await File.WriteAllTextAsync(filePath, content, TestContext.Current.CancellationToken); + } + + /// + /// A bare console app that consumes Steeltoe.Management.GitProperties.Build the way a real, external user would - via <PackageReference> against a + /// built .nupkg - as opposed to every other test here, which uses a ProjectReference/Import straight against source. No explicit <Import> of the + /// .targets file: that's the whole point of the "build\{PackageId}.targets" NuGet auto-import convention this package relies on, and this is the only + /// test that actually exercises it end-to-end. + /// + public static async Task CreatePackageConsumerProjectAsync(string projectDirectory, string packageVersion) + { + Directory.CreateDirectory(projectDirectory); + + string projectContent = $""" + + + Exe + {TestPaths.TestAppTargetFramework} + enable + + true + + + + + + + """; + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent, TestContext.Current.CancellationToken); + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), """ + Console.WriteLine("Hello, World!"); + """, TestContext.Current.CancellationToken); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs index 90e7551227..66c607080e 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -23,7 +23,8 @@ public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublish await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - string pushedRoot = GitPropertiesTestWorkspace.SimulateSourcePush(repository, Path.Combine(Workspace.RootDirectory, "pushed")); + string destinationDirectory = Path.Combine(Workspace.RootDirectory, "pushed"); + string pushedRoot = SyntheticGitRepositoryBuilder.SimulateSourcePush(repository, destinationDirectory); string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); From cd8e5252203a3a091c33af118cd9db703fe5c5ed Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:56:16 +0200 Subject: [PATCH 12/20] Cleanup tests, fixing primitive obsession antipattern --- .../BuildOutputAssertions.cs | 27 +++++ ...ChangesAcrossBuildsUnlikeCommitTimeTest.cs | 12 +- .../EmptyGitRepository.cs | 32 ++++++ ...backFileIgnoredWhenLiveGitAvailableTest.cs | 11 +- .../FallbackFileUsedWhenNoGitAvailableTest.cs | 20 ++-- ...itignoreMakesLaterBuildsAppearDirtyTest.cs | 14 +-- ...GitExecutableNotFoundWarnsByDefaultTest.cs | 21 ++-- .../GitFileWarnsByDefaultTest.cs | 22 ++-- .../GitPropertiesBuildTestBase.cs | 59 +--------- .../GitPropertiesTestWorkspace.cs | 92 +++++++++++----- .../GitProperties.Build.Test/GitRepository.cs | 104 ++++++++++++++++++ ...toryBuilder.cs => GitRepositoryBuilder.cs} | 10 +- .../GroundTruthAllPropertiesMatchGitTest.cs | 25 ++--- ...talBuildCacheSkipsButDirtyStaysLiveTest.cs | 13 +-- .../MultiProjectSharesCacheTest.cs | 22 ++-- ...ctSharesCacheAcrossTargetFrameworksTest.cs | 19 ++-- .../MultipleRemotesOnlyOriginUrlIsUsedTest.cs | 26 ++--- .../NewTagInvalidatesCacheTest.cs | 16 ++- .../NoCommitsWarnsByDefaultTest.cs | 19 ++-- .../NoGitInfoWhenEnableWarningsFalseTest.cs | 11 +- .../NoGitWarnsByDefaultTest.cs | 11 +- .../NonAsciiCommitDataRendersCorrectlyTest.cs | 24 ++-- ...kageReferenceGeneratesGitPropertiesTest.cs | 19 ++-- .../GitProperties.Build.Test/ProcessRunner.cs | 9 +- .../PublishIncludesGitPropertiesTest.cs | 10 +- ...PublishNoBuildIncludesGitPropertiesTest.cs | 13 +-- .../RemotePushProjectTree.cs | 18 +++ ...lowCloneInfoWhenEnableWarningsFalseTest.cs | 13 +-- ...ShallowCloneLeavesCommitCountsEmptyTest.cs | 21 ++-- ...erDetectedConsumingPackageReferenceTest.cs | 13 +-- ...rtiesWhenConsumingPackageReferencedTest.cs | 15 ++- ...aultOverrideDetectsCustomPackageIdsTest.cs | 17 +-- ...errideDoesNotMatchPackageIdAsPrefixTest.cs | 14 ++- ...alPropertySkipsGenerationGracefullyTest.cs | 13 +-- ...tionWhenNoConsumingPackageReferenceTest.cs | 8 +- .../GitProperties.Build.Test/TestProject.cs | 104 ++++++++++++++++++ .../TestProjectWriter.cs | 2 +- ...roducesFallbackFileWithoutCompilingTest.cs | 16 ++- ...FallbackFileThenPublishNoBuildFailsTest.cs | 10 +- ...henSimulatedPushServerPublishUsesItTest.cs | 18 ++- ...rtiesFallbackFileWorksWithNoRestoreTest.cs | 11 +- ...DirectoryCreatesFallbackFileOnBuildTest.cs | 22 ++-- ...rectoryCreatesFallbackFileOnPublishTest.cs | 19 ++-- 43 files changed, 598 insertions(+), 397 deletions(-) create mode 100644 src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs create mode 100644 src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GitRepository.cs rename src/Management/test/GitProperties.Build.Test/{SyntheticGitRepositoryBuilder.cs => GitRepositoryBuilder.cs} (93%) create mode 100644 src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs create mode 100644 src/Management/test/GitProperties.Build.Test/TestProject.cs diff --git a/src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs b/src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs new file mode 100644 index 0000000000..11e62cd5ff --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.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; + +/// +/// Fluent assertions against the captured stdout/stderr of a "dotnet build"/"publish" call, for the two diagnostic-severity checks nearly every +/// warn-by-default test repeats. +/// +internal static class BuildOutputAssertions +{ + public static void AssertWarned(this string output, string code) + { + output.Should().Contain($"warning {code}"); + } + + /// + /// GitPropertiesEnableWarnings=false downgrades a diagnostic from a warning to a plain informational message - with no code at all (see + /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why). + /// + public static void AssertReportedAsInfoOnly(this string output, string code, string messageSnippet) + { + output.Should().NotContain(code); + output.Should().Contain(messageSnippet); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs index 1f35694150..1dc850274a 100644 --- a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs +++ b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs @@ -15,11 +15,9 @@ public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitProper [Fact] public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunDotnetAsync(testApp, "build"); - Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync(); + Dictionary propertiesBefore = await repository.TestApp.ReadDebugPropertiesAsync(); // git.build.time is formatted "yyyy-MM-ddTHH:mm:sszzz" (ComposeGitPropertiesTask) - second // resolution only, no fractional part - so two builds landing within the same wall-clock @@ -27,8 +25,8 @@ public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() // sized to that format's own precision, not incidental slack. await Task.Delay(TimeSpan.FromMilliseconds(1100), TestContext.Current.CancellationToken); - await ProcessRunner.RunDotnetAsync(testApp, "build"); - Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + await repository.TestApp.BuildAsync(); + Dictionary propertiesAfter = await repository.TestApp.ReadDebugPropertiesAsync(); propertiesAfter["git.build.time"].Should().NotBe(propertiesBefore["git.build.time"], "git.build.time must be recomputed on every build, not reused from the shared cache."); 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..c3173ab471 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.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; + +/// +/// A git repository directory with no TestApp (or any other project) written into it yet - just "git init", so a test can run fully custom git commands +/// (config, commits, tags...) before any project files exist, without those files ending up swept into whatever it commits along the way. Created by +/// - never directly. Call once ready to build +/// against it, which upgrades this into a full . +/// +internal sealed class EmptyGitRepository(GitPropertiesTestWorkspace workspace, string rootDirectory) +{ + public string RootDirectory { get; } = rootDirectory; + + public Task RunGitAsync(params string[] arguments) + { + return ProcessRunner.RunGitAsync(RootDirectory, arguments); + } + + /// + /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source into this repository and writes the default TestApp project referencing it - see + /// . Deliberately does not commit anything: this is meant for the "fully custom setup" + /// scenario where any commit is already the caller's own responsibility. + /// + public async Task AddTestAppAsync() + { + TestProject testApp = await GitRepository.WriteDefaultTestAppAsync(RootDirectory); + return new GitRepository(workspace, RootDirectory, testApp); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs index a1421e8edf..19b75de5e3 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs @@ -13,17 +13,16 @@ public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesB [Fact] public async Task FallbackFile_Ignored_WhenLiveGitAvailable() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); - await File.WriteAllLinesAsync(GetFallbackFilePath(testApp), ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], + await File.WriteAllLinesAsync(repository.TestApp.FallbackFilePath, ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], TestContext.Current.CancellationToken); - string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + string result = await repository.TestApp.BuildAsync("-v:detailed"); result.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + 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/FallbackFileUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs index 435e99b524..7e7dbc65cd 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs @@ -15,24 +15,20 @@ public sealed class FallbackFileUsedWhenNoGitAvailableTest : GitPropertiesBuildT [Fact] public async Task FallbackFile_UsedWhenNoGitAvailable() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + 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", "the gitignored fallback file must not make its own producing build see the tree as dirty."); - string destinationDirectory = Path.Combine(Workspace.RootDirectory, "pushed"); - string pushedRoot = SyntheticGitRepositoryBuilder.SimulateSourcePush(repository, destinationDirectory); - string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); - Directory.Exists(Path.Combine(pushedRoot, ".git")).Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); - File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); + RemotePushProjectTree remote = repository.SimulatePush("pushed"); + remote.HasGitDirectory.Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); + remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback git.properties must have survived the simulated push."); - string publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); + string publishResult = await remote.TestApp.PublishAsync("-v:detailed"); publishResult.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); publishResult.Should().Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); + Dictionary publishedProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); } } diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs index 6ddd9c77bc..e08d84090a 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs @@ -15,17 +15,15 @@ public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest [Fact] public async Task FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + bool isDirty = await repository.IsDirtyAsync(); + isDirty.Should().BeTrue("the freshly-written, ungitignored fallback file should show up as an untracked change."); - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - gitStatus.Should().NotBeEmpty("the freshly-written, ungitignored fallback file should show up as an untracked change."); + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); - - Dictionary properties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + Dictionary properties2 = await repository.TestApp.ReadDebugPropertiesAsync(); properties2["git.dirty"].Should().Be("true", "the ungitignored fallback file left over from the first build makes every later build see the tree as dirty."); diff --git a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs index 9b8436985d..3d34b3d261 100644 --- a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs @@ -17,21 +17,18 @@ public sealed class GitExecutableNotFoundWarnsByDefaultTest : GitPropertiesBuild [Fact] public async Task GitExecutableNotFound_WarnsByDefault() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string defaultResult = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}"); + defaultResult.AssertWarned("GITPROPS003"); - string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitExecutable={BogusGitExecutable}"); - AssertWarned(defaultResult, "GITPROPS003"); - AssertNoGitPropertiesGenerated(testApp); + string enableWarningsFalseResult = + await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - string enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitExecutable={BogusGitExecutable}", - "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - - AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS003", "could not run"); - - string featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitExecutable={BogusGitExecutable}", - "-p:GenerateGitProperties=false"); + enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS003", "could not run"); + string featureOffResult = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GenerateGitProperties=false"); featureOffResult.Should().NotContain("GITPROPS003"); + + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs index c9cac43866..dcaf8968ba 100644 --- a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs @@ -9,22 +9,24 @@ public sealed class GitFileWarnsByDefaultTest : GitPropertiesBuildTestBase [Fact] public async Task GitFile_WarnsByDefault() { - string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); - Directory.CreateDirectory(projectDirectory); - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(projectDirectory); + string projectDirectory = Workspace.GetPath("test-project"); + TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); + // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk // (which starts at TestApp, the project actually being built) to find it - i.e. at // projectDirectory itself. - await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/proj", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/test-project", + TestContext.Current.CancellationToken); - string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertWarned(defaultResult, "GITPROPS002"); - AssertNoGitPropertiesGenerated(testApp); + string defaultResult = await testApp.BuildAsync(); + defaultResult.AssertWarned("GITPROPS002"); - string enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS002", "resolves to a git worktree or submodule"); + string enableWarningsFalseResult = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS002", "resolves to a git worktree or submodule"); - string featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); + string featureOffResult = await testApp.BuildAsync("-p:GenerateGitProperties=false"); featureOffResult.Should().NotContain("GITPROPS002"); + + testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs index 6ecf966d1a..610dc440ac 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs @@ -5,16 +5,16 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// -/// Shared workspace/assertion plumbing for every test in this project. Split one test per class - rather than many [Fact] methods on one shared -/// class - purely for parallelism: xUnit v3 runs different test classes concurrently by default (no configuration or version upgrade needed - verified +/// Shared workspace lifecycle for every test in this project. Split one test per class - rather than many [Fact] methods on one shared class - +/// purely for parallelism: xUnit v3 runs different test classes concurrently by default (no configuration or version upgrade needed - verified /// empirically), but never parallelizes methods within the SAME class. Since every test here is dominated by "dotnet build"/"publish" subprocess time /// that's mostly I/O/wait-bound, not CPU-bound (measured: 4 concurrent builds complete in ~1.3x one build's time, 8 concurrent in ~1.9x), /// one-class-per-test lets the whole suite's wall-clock approach its slowest single test instead of the sum of all of them. /// /// -/// Public (xUnit only discovers public test classes), but every member below is internal rather than protected: GitPropertiesTestWorkspace is itself -/// internal, and a protected member of a public class can't expose a less-accessible type in its signature (CS0051/CS0052). Internal still works the -/// same way for every derived class here, since they all live in this same assembly. Implements IAsyncLifetime (not a constructor) to create +/// Public (xUnit only discovers public test classes), but is internal rather than protected: GitPropertiesTestWorkspace is +/// itself internal, and a protected member of a public class can't expose a less-accessible type in its signature (CS0051/CS0052). Internal still works +/// the same way for every derived class here, since they all live in this same assembly. Implements IAsyncLifetime (not a constructor) to create /// : GitPropertiesTestWorkspace.CreateAsync itself awaits a "pwd -P" subprocess on macOS to resolve a symlink-free root path, /// and a constructor can't await. /// @@ -33,53 +33,4 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); return ValueTask.CompletedTask; } - - internal static async Task>> GetGitPropertiesPerTargetFrameworkAsync(string projectDirectory, string[] frameworks) - { - List> result = []; - - foreach (string framework in frameworks) - { - Dictionary properties = await PropertiesFile.ReadAsync(Path.Combine(projectDirectory, "bin", "Debug", framework, "git.properties")); - result.Add(properties); - } - - return result; - } - - internal static string GetFallbackFilePath(string projectDirectory) - { - return Path.Combine(projectDirectory, "git.properties"); - } - - internal static void AssertWarned(string output, string code) - { - output.Should().Contain($"warning {code}"); - } - - /// - /// GitPropertiesEnableWarnings=false downgrades a diagnostic from a Warning to a plain informational message - with no code at all (see - /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why), and at Importance="Normal" rather than the default's "high", so it's visible at - /// "-v:normal" but not in default build output. - /// - internal static void AssertReportedAsInfoOnly(string output, string code, string messageSnippet) - { - output.Should().NotContain(code, "a downgraded message must never carry a code - only warnings do."); - output.Should().Contain(messageSnippet); - } - - internal static void AssertNoGitPropertiesGenerated(string projectDirectory) - { - File.Exists(GetDebugGitPropertiesFilePath(projectDirectory)).Should().BeFalse("no git.properties should be generated."); - } - - internal static string GetDebugGitPropertiesFilePath(string projectDirectory) - { - return Path.Combine(projectDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - } - - internal static string GetReleasePublishGitPropertiesFilePath(string projectDirectory) - { - return Path.Combine(projectDirectory, "bin", "Release", TestPaths.TestAppTargetFramework, "publish", "git.properties"); - } } diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index 9346385358..9d03333362 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -7,16 +7,11 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// -/// An isolated temporary directory tree for a single test, cleaned up on Dispose - and the entry point every test uses to set up its own synthetic -/// project/repository layout. Owns only its own lifecycle plus the couple of methods that need its own ; the actual git-repo -/// mechanics live in and project/package file writing lives in - both are -/// exposed here as thin forwarding methods purely so every test can keep calling through -/// -/// Workspace -/// -/// / without needing to know which of the three classes actually implements a given piece. Deliberately avoids -/// "gitprop" in its own name (in any casing) so a test's workspace path can never accidentally satisfy an Assert.Contains/DoesNotContain check against a -/// GITPROPS0xx diagnostic code in build output, which routinely echoes back the working directory path. +/// An isolated temporary directory tree for a single test, cleaned up on Dispose - and the factory every test uses to create the +/// / instances its scenario needs, so no test has to combine a path under +/// itself. Deliberately avoids "gitprop" in its own name (in any casing) so a test's workspace path can never accidentally +/// satisfy an Assert.Contains/DoesNotContain check against a GITPROPS0xx diagnostic code in build output, which routinely echoes back the working +/// directory path. /// /// /// Constructed via rather than a public constructor: resolving the physical (symlink-free) root directory on macOS needs a @@ -99,35 +94,82 @@ private static void ClearReadOnlyAttributes(DirectoryInfo directory) } } + /// + /// Combines a short, per-test name (e.g. "repo", "proj") with - the one remaining path-combining primitive every factory + /// method below (and the occasional test that needs a workspace-scoped scratch path with no object of its own, e.g. an isolated NuGet packages folder) + /// is built on. + /// + public string GetPath(string name) + { + return Path.Combine(RootDirectory, name); + } + + /// + /// A directory with no git repository at all, containing just a copy of the CURRENT Steeltoe.Management.GitProperties.Build source and its default + /// TestApp - see . For tests that specifically cover the "no usable git repository above + /// this project" diagnostics. + /// + public async Task CreateProjectDirectoryAsync(string name) + { + string directory = GetPath(name); + Directory.CreateDirectory(directory); + string appDirectory = await TestProjectWriter.CopyCurrentProjectFilesAsync(directory); + return new TestProject(appDirectory, TestAppProjectName); + } + + /// + /// A freshly-initialized git repository with zero commits - "git init" only, no config/.gitignore/manufactured history. For tests that specifically + /// cover the "repository has no commits yet" diagnostic, and any scenario that wants full manual control over its own git commands before adding + /// projects (see ). + /// + public async Task CreateEmptyRepositoryAsync(string name) + { + string directory = GetPath(name); + Directory.CreateDirectory(directory); + await ProcessRunner.RunGitAsync(directory, "init", "--quiet", "--initial-branch=main", "."); + return new EmptyGitRepository(this, directory); + } + /// /// A brand-new, synthetic git repo with a controlled, minimal history (`git init` plus a handful of manufactured commits, via - /// ) - deliberately never a clone of this (large, real) repository, so the suite stays fast. - /// Returns the repository root (not the TestApp directory - callers combine "TestApp" themselves, mirroring how multi-project tests place additional - /// sibling projects at the same root). + /// ) plus the default TestApp - deliberately never a clone of this (large, real) repository, so the + /// suite stays fast. /// - /// - /// The directory to initialize the repository in. + /// + /// The directory to initialize the repository in, relative to . /// /// /// The number of manufactured commits to create before the project files are added. /// /// /// Whether to also list "git.properties" in the repository's .gitignore, modeling the setup a real consumer of $(GitPropertiesWriteToProjectDirectory) - /// must follow - see for the complementary "not .cfignore'd" half of that same guidance. - /// Defaults to false so most tests (which never write a fallback file into the project directory at all) aren't given a gitignore entry they don't - /// exercise - only pass true for tests that specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that - /// accidentally wrote one in a test that doesn't expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being - /// silently absorbed by a blanket ignore rule. + /// must follow - see for the complementary "not .cfignore'd" half of that same guidance. Defaults + /// to false so most tests (which never write a fallback file into the project directory at all) aren't given a gitignore entry they don't exercise - + /// only pass true for tests that specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that accidentally wrote + /// one in a test that doesn't expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being silently absorbed by + /// a blanket ignore rule. /// - public async Task CreateSyntheticRepoAsync(string destination, int commitCount, bool gitignoreFallbackFile = false) + public async Task CreateGitRepositoryAsync(string name, int commitCount, bool gitignoreFallbackFile = false) { - await SyntheticGitRepositoryBuilder.InitializeAsync(destination, commitCount, gitignoreFallbackFile); - await TestProjectWriter.CopyCurrentProjectFilesAsync(destination); + string directory = GetPath(name); + await GitRepositoryBuilder.InitializeAsync(directory, commitCount, gitignoreFallbackFile); + TestProject testApp = await GitRepository.WriteDefaultTestAppAsync(directory); + var repository = new GitRepository(this, directory, testApp); // Commit the project files too, so the synthetic repo starts clean (git.dirty=false) // unless a test deliberately makes a further change - otherwise every synthetic repo would // show git.dirty=true purely because of these untracked-but-just-added files. - await SyntheticGitRepositoryBuilder.CommitAllAsync(destination, "Add project files"); - return destination; + await GitRepositoryBuilder.CommitAllAsync(directory, "Add project files"); + return repository; + } + + public Task PackGitPropertiesBuildToFeedAsync() + { + return TestProjectWriter.PackGitPropertiesBuildToFeedAsync(RootDirectory); + } + + public Task WriteIsolatedNuGetConfigAsync(TestProject project, string feedDirectory) + { + return TestProjectWriter.WriteIsolatedNuGetConfigAsync(Path.Combine(project.RootDirectory, "nuget.config"), feedDirectory); } } 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..b6c1cdef17 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitRepository.cs @@ -0,0 +1,104 @@ +// 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; + +/// +/// A synthetic git repository directory under test, with its default already written into it. Wraps the directory so a test can +/// run git commands, add further projects, or simulate a source-based push against it without re-deriving its path or reaching for +/// / itself. Created by or +/// - never directly. For a repository that doesn't have (or need) a TestApp - a fully custom git setup +/// - see instead. +/// +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, string? 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); + } + + /// + /// A bare console app consuming Steeltoe.Management.GitProperties.Build via <PackageReference> - see + /// . Placed inside this repository (not directly under the workspace root), so the + /// repo-root walk that starts at the consumer project still finds this repository's own ".git" above it. + /// + public async Task AddPackageConsumerProjectAsync(string name, string packageVersion) + { + string projectDirectory = Path.Combine(rootDirectory, name); + await TestProjectWriter.CreatePackageConsumerProjectAsync(projectDirectory, packageVersion); + return new TestProject(projectDirectory, name); + } + + /// + /// A shallow (--depth) clone of this repository, with the CURRENT Steeltoe.Management.GitProperties.Build source and a fresh TestApp copied in - see + /// . --no-local is required here: for a plain local filesystem path, git's local-clone + /// optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, producing a full clone that would make a shallow-clone test + /// worthless. + /// + public async Task CloneAsShallowAsync(string name, int depth = 1) + { + string destination = workspace.GetPath(name); + 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 RemotePushProjectTree SimulatePush(string name) + { + string destination = workspace.GetPath(name); + string pushRoot = GitRepositoryBuilder.SimulateSourcePush(rootDirectory, destination); + + var pushedTestApp = new TestProject(Path.Combine(pushRoot, GitPropertiesTestWorkspace.TestAppProjectName), + GitPropertiesTestWorkspace.TestAppProjectName); + + return new RemotePushProjectTree(pushRoot, pushedTestApp); + } + + /// + /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source into and writes the default TestApp project + /// referencing it - the one piece and (and, via + /// , the fully-custom-setup path) all share. + /// + 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/SyntheticGitRepositoryBuilder.cs b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs similarity index 93% rename from src/Management/test/GitProperties.Build.Test/SyntheticGitRepositoryBuilder.cs rename to src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs index a770ab2303..5c36f9387f 100644 --- a/src/Management/test/GitProperties.Build.Test/SyntheticGitRepositoryBuilder.cs +++ b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs @@ -7,10 +7,10 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// /// Builds synthetic git repositories with a controlled, minimal commit history for tests, and simulates the one deployment step (`cf push`) that strips /// ".git" from what a running app actually sees on disk. Deliberately never operates on a clone of this (large, real) repository, so the suite stays -/// fast - see for the workspace-level entry point that also copies in the project +/// fast - see for the workspace-level entry point that also copies in the project /// files under test. /// -internal static class SyntheticGitRepositoryBuilder +internal static class GitRepositoryBuilder { private static readonly HashSet SimulatedPushExcludedDirectoryNames = new(StringComparer.OrdinalIgnoreCase) { @@ -21,7 +21,7 @@ internal static class SyntheticGitRepositoryBuilder /// /// `git init` plus a handful of manufactured commits under . Deliberately stops short of committing anything beyond those - /// manufactured files - see for the project-files-copy and final commit step this + /// manufactured files - see for the project-files-copy and final commit step this /// leaves to its caller. /// /// @@ -31,7 +31,7 @@ internal static class SyntheticGitRepositoryBuilder /// The number of manufactured commits to create. /// /// - /// Whether to also list "git.properties" in the repository's .gitignore - see for the + /// Whether to also list "git.properties" in the repository's .gitignore - see for the /// full explanation. /// public static async Task InitializeAsync(string destination, int commitCount, bool gitignoreFallbackFile) @@ -68,7 +68,7 @@ await File.WriteAllTextAsync(Path.Combine(destination, $"file{commitNumber}.txt" } /// - /// A small "git add -A / git commit" primitive - used by to commit the project files + /// A small "git add -A / git commit" primitive - used by to commit the project files /// it copies in after runs. /// public static async Task CommitAllAsync(string repositoryDirectory, string message) diff --git a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs index 6c8f1cfad1..d533d10732 100644 --- a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs @@ -43,39 +43,36 @@ public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTes [Fact] public async Task GroundTruth_AllPropertiesMatchGit() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 3); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 3); + string result = await repository.TestApp.BuildAsync(); - string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeFalse( + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeFalse( "the fallback file must not be written into the project directory unless explicitly opted into."); string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); result.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + string expectedCommitId = await repository.GetCommitIdAsync(); properties["git.commit.id"].Should().Be(expectedCommitId); - string expectedCommitIdAbbrev = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "--short=7", "HEAD"); + string expectedCommitIdAbbrev = await repository.RunGitAsync("rev-parse", "--short=7", "HEAD"); properties["git.commit.id.abbrev"].Should().Be(expectedCommitIdAbbrev); - string expectedCommitUserName = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%an"); + string expectedCommitUserName = await repository.RunGitAsync("log", "-1", "--format=%an"); properties["git.commit.user.name"].Should().Be(expectedCommitUserName); - string expectedCommitUserEmail = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%ae"); + string expectedCommitUserEmail = await repository.RunGitAsync("log", "-1", "--format=%ae"); properties["git.commit.user.email"].Should().Be(expectedCommitUserEmail); - string expectedCommitMessageShort = await ProcessRunner.GetGitOutputAsync(repository, "log", "-1", "--format=%s"); + string expectedCommitMessageShort = await repository.RunGitAsync("log", "-1", "--format=%s"); properties["git.commit.message.short"].Should().Be(expectedCommitMessageShort); - string expectedTotalCommitCount = await ProcessRunner.GetGitOutputAsync(repository, "rev-list", "--count", "HEAD"); + string expectedTotalCommitCount = await repository.RunGitAsync("rev-list", "--count", "HEAD"); properties["git.total.commit.count"].Should().Be(expectedTotalCommitCount); - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - bool expectedDirty = gitStatus.Length > 0; + bool expectedDirty = await repository.IsDirtyAsync(); properties["git.dirty"].Should().Be(expectedDirty ? "true" : "false"); // SDK default when $(Version) isn't set. diff --git a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs index 414aebf89d..bfca75423b 100644 --- a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs +++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs @@ -9,21 +9,18 @@ public sealed class IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropert [Fact] public async Task IncrementalBuild_CacheSkipsButDirtyStaysLive() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync(); + repository.SharedCacheExists.Should().BeTrue("the cache file should exist after first build."); - await ProcessRunner.RunDotnetAsync(testApp, "build"); - string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); - File.Exists(cacheFile).Should().BeTrue("the cache file should exist after first build."); - - string result2 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + string result2 = await repository.TestApp.BuildAsync("-v:detailed"); // "Skipping target" is itself the deterministic, sufficient proof that nothing rewrote the // cache file on this second build - no last-write-time comparison (and the sleep it would // otherwise need, to guarantee a detectably different timestamp) is needed on top of it. result2.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); properties.Should().ContainKey("git.dirty"); } } diff --git a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs index 8a978954c2..c92a30da9a 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs @@ -9,35 +9,31 @@ public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase [Fact] public async Task MultiProject_SharesCache() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2); // Two independent projects at the repo root, each with their own ProjectReference/Import - // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepo + // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepository // already placed TestApp there; reuse that exact relative layout for ProjectA/ProjectB by placing them // at the repo root too, siblings of TestApp and "src"). - await TestProjectWriter.WriteAppProjectAsync(repository, "ProjectA"); - await TestProjectWriter.WriteAppProjectAsync(repository, "ProjectB"); + TestProject projectA = await repository.AddProjectAsync("ProjectA"); + TestProject projectB = await repository.AddProjectAsync("ProjectB"); - string projectA = Path.Combine(repository, "ProjectA"); - string projectB = Path.Combine(repository, "ProjectB"); - - string resultA = await ProcessRunner.RunDotnetAsync(projectA, "build", "-v:detailed"); + string resultA = await projectA.BuildAsync("-v:detailed"); resultA.Should().Contain("git.properties: generating shared cache", "ProjectA (first to build) should be the one that actually generates the shared cache."); - string cacheFile = Path.Combine(repository, "obj", "_GitProperties", "git.properties.cache"); - File.Exists(cacheFile).Should().BeTrue("ProjectA's build should have generated the shared cache."); + repository.SharedCacheExists.Should().BeTrue("ProjectA's build should have generated the shared cache."); - string resultB = await ProcessRunner.RunDotnetAsync(projectB, "build", "-v:detailed"); + string resultB = await projectB.BuildAsync("-v:detailed"); // "did not log generating the cache" is itself the deterministic, sufficient proof ProjectB // reused ProjectA's cache instead of rewriting it - no last-write-time comparison (and the // sleep it would otherwise need) is needed on top of it. resultB.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); - Dictionary propertiesA = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectA)); - Dictionary propertiesB = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(projectB)); + 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/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs index b28e51232d..10fec007f3 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs @@ -17,14 +17,13 @@ public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : [Fact] public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, "MultiTargetApp", TestPaths.MultiTargetTestFrameworks); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject testApp = await repository.AddProjectAsync("MultiTargetApp", TestPaths.MultiTargetTestFrameworks); string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';'); + await testApp.BuildAsync(); - await ProcessRunner.RunDotnetAsync(testApp, "build"); - - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); - List> propertiesBefore = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); + string expectedCommitId = await repository.GetCommitIdAsync(); + List> propertiesBefore = await testApp.ReadDebugPropertiesPerTargetFrameworkAsync(frameworks); foreach (Dictionary properties in propertiesBefore) { @@ -32,11 +31,9 @@ public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() properties["git.tags"].Should().BeEmpty(); } - await ProcessRunner.RunGitAsync(repository, "tag", "v1.0.0"); - - await ProcessRunner.RunDotnetAsync(testApp, "build"); - - List> propertiesAfter = await GetGitPropertiesPerTargetFrameworkAsync(testApp, frameworks); + await repository.TagAsync("v1.0.0"); + await testApp.BuildAsync(); + List> propertiesAfter = await testApp.ReadDebugPropertiesPerTargetFrameworkAsync(frameworks); foreach (Dictionary properties in propertiesAfter) { diff --git a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs index 6cabab8e16..ba75f50539 100644 --- a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs @@ -18,16 +18,12 @@ public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildT [Fact] public async Task MultipleRemotes_OnlyOriginUrlIsUsed() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunGitAsync(repository, "remote", "add", "upstream", "https://example.com/upstream.git"); - await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "https://example.com/origin.git"); - await ProcessRunner.RunGitAsync(repository, "remote", "set-url", "--add", "origin", "https://user:pass@example.com/origin-second.git"); - - await ProcessRunner.RunDotnetAsync(testApp, "build"); - - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + 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", "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + @@ -43,12 +39,10 @@ public async Task MultipleRemotes_OnlyOriginUrlIsUsed() // --add"), and git refuses a plain "set-url" against a remote with multiple values ("fatal: could not set // 'remote.origin.url' ... has multiple values") - remove-then-add is the clean way to replace all of them // with exactly one. - await ProcessRunner.RunGitAsync(repository, "remote", "remove", "origin"); - await ProcessRunner.RunGitAsync(repository, "remote", "add", "origin", "git@github.com:org/repo.git"); - - await ProcessRunner.RunDotnetAsync(testApp, "build"); - - Dictionary propertiesAfterScpStyleUrl = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + 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", "a non-absolute, scp-style remote URL must be left exactly as-is - there is nothing safe for StripUserInfo to rewrite."); diff --git a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs index 5a9c021003..60a7f11ab4 100644 --- a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs @@ -16,18 +16,16 @@ public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase [Fact] public async Task NewTag_InvalidatesCache() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunDotnetAsync(testApp, "build"); - Dictionary propertiesBefore = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + 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 ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD~1"); - await ProcessRunner.RunGitAsync(repository, "tag", "release-1.0", ancestorCommitId); + string ancestorCommitId = await repository.RunGitAsync("rev-parse", "HEAD~1"); + await repository.TagAsync("release-1.0", ancestorCommitId); - await ProcessRunner.RunDotnetAsync(testApp, "build"); - Dictionary propertiesAfter = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + 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"); diff --git a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs index 5bad00309c..1cc0146f8d 100644 --- a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs @@ -13,19 +13,18 @@ public sealed class NoCommitsWarnsByDefaultTest : GitPropertiesBuildTestBase [Fact] public async Task NoCommits_WarnsByDefault() { - string repository = Path.Combine(Workspace.RootDirectory, "repo"); - Directory.CreateDirectory(repository); - await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(repository); + EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); + GitRepository repository = await emptyRepository.AddTestAppAsync(); - string defaultResult = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertWarned(defaultResult, "GITPROPS005"); - AssertNoGitPropertiesGenerated(testApp); + string defaultResult = await repository.TestApp.BuildAsync(); + defaultResult.AssertWarned("GITPROPS005"); - string enableWarningsFalseResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertReportedAsInfoOnly(enableWarningsFalseResult, "GITPROPS005", "no commits yet"); + string enableWarningsFalseResult = await repository.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS005", "no commits yet"); - string featureOffResult = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); + string featureOffResult = await repository.TestApp.BuildAsync("-p:GenerateGitProperties=false"); featureOffResult.Should().NotContain("GITPROPS005"); + + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs index 4be4b3adbd..f679d9b261 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs @@ -9,12 +9,9 @@ public sealed class NoGitInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTes [Fact] public async Task NoGit_InfoWhenEnableWarningsFalse() { - string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); - Directory.CreateDirectory(projectDirectory); - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(projectDirectory); - - string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertReportedAsInfoOnly(result, "GITPROPS001", "no usable .git directory found above"); - AssertNoGitPropertiesGenerated(testApp); + TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); + string result = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + result.AssertReportedAsInfoOnly("GITPROPS001", "no usable .git directory found above"); + testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs index 6d0e5115fb..a5609625ef 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs @@ -9,12 +9,9 @@ public sealed class NoGitWarnsByDefaultTest : GitPropertiesBuildTestBase [Fact] public async Task NoGit_WarnsByDefault() { - string projectDirectory = Path.Combine(Workspace.RootDirectory, "proj"); - Directory.CreateDirectory(projectDirectory); - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(projectDirectory); - - string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); - AssertWarned(result, "GITPROPS001"); - AssertNoGitPropertiesGenerated(testApp); + TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); + string result = await testApp.BuildAsync(); + result.AssertWarned("GITPROPS001"); + testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs index 6eccf7720e..53122dfe9b 100644 --- a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -15,27 +15,25 @@ public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildT [Fact] public async Task NonAscii_CommitDataRendersCorrectly() { - string repository = Path.Combine(Workspace.RootDirectory, "repo"); - Directory.CreateDirectory(repository); - await ProcessRunner.RunGitAsync(repository, "init", "--quiet", "--initial-branch=main", "."); + EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); + // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). 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()."; - await ProcessRunner.RunGitAsync(repository, "config", "user.name", nonAsciiUserName); - await ProcessRunner.RunGitAsync(repository, "config", "user.email", "test@example.com"); - await File.WriteAllTextAsync(Path.Combine(repository, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(repository, "file.txt"), "content", TestContext.Current.CancellationToken); - await ProcessRunner.RunGitAsync(repository, "add", "-A"); - await ProcessRunner.RunGitAsync(repository, "commit", "--quiet", "-m", nonAsciiCommitSubject, "-m", commitBody); - - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(repository); + await emptyRepository.RunGitAsync("config", "user.name", nonAsciiUserName); + await emptyRepository.RunGitAsync("config", "user.email", "test@example.com"); + await File.WriteAllTextAsync(Path.Combine(emptyRepository.RootDirectory, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(emptyRepository.RootDirectory, "file.txt"), "content", TestContext.Current.CancellationToken); + await emptyRepository.RunGitAsync("add", "-A"); + await emptyRepository.RunGitAsync("commit", "--quiet", "-m", nonAsciiCommitSubject, "-m", commitBody); - await ProcessRunner.RunDotnetAsync(testApp, "build"); + GitRepository repository = await emptyRepository.AddTestAppAsync(); + await repository.TestApp.BuildAsync(); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); properties["git.commit.user.name"].Should().Be(nonAsciiUserName); properties["git.commit.message.short"].Should().Be(nonAsciiCommitSubject, "the short/subject line is never multi-line to begin with."); diff --git a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs index 3c5566a418..2d305f5f6b 100644 --- a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -20,11 +20,9 @@ public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertie [Fact] public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - - string feedDirectory = await TestProjectWriter.PackGitPropertiesBuildToFeedAsync(Workspace.RootDirectory); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); string packageId = await TestPaths.GetPackageIdAsync(); - string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); nuPkgFiles.Should().ContainSingle("packing should produce exactly one .nupkg."); @@ -33,12 +31,11 @@ public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitPropertie versionMatch.Success.Should().BeTrue("the .nupkg file name should embed the package version."); string packageVersion = versionMatch.Groups[1].Value; - string consumerDirectory = Path.Combine(repository, "Consumer"); - await TestProjectWriter.CreatePackageConsumerProjectAsync(consumerDirectory, packageVersion); - await TestProjectWriter.WriteIsolatedNuGetConfigAsync(Path.Combine(consumerDirectory, "nuget.config"), feedDirectory); + TestProject consumer = await repository.AddPackageConsumerProjectAsync("Consumer", packageVersion); + await Workspace.WriteIsolatedNuGetConfigAsync(consumer, feedDirectory); - string isolatedPackagesPath = Path.Combine(Workspace.RootDirectory, "isolated-packages"); - string result = await ProcessRunner.RunDotnetAsync(consumerDirectory, "build", $"-p:RestorePackagesPath={isolatedPackagesPath}"); + string isolatedPackagesPath = Workspace.GetPath("isolated-packages"); + string result = await consumer.BuildAsync($"-p:RestorePackagesPath={isolatedPackagesPath}"); result.Should().Contain("0 Warning(s)", "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); // NuGet always lowercases the package ID for the on-disk global-packages-folder layout - this isn't @@ -51,8 +48,8 @@ public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitPropertie Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should().BeTrue( "the package should restore into the isolated path, never the machine-wide global-packages cache."); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(consumerDirectory)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + 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/ProcessRunner.cs b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs index b85493b3b7..0f87671b83 100644 --- a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -186,7 +186,8 @@ public static Task RunGitAsync(string workingDirectory, params string[] public static async Task RunGitAsync(string workingDirectory, CancellationToken cancellationToken, params string[] arguments) { string gitExecutable = await RealGitExecutableTask; - return await RunAsync(gitExecutable, workingDirectory, 0, cancellationToken, arguments); + string output = await RunAsync(gitExecutable, workingDirectory, 0, cancellationToken, arguments); + return output.Trim(); } /// @@ -222,12 +223,6 @@ private static string[] BuildDotnetArguments(string[] arguments) ]; } - public static async Task GetGitOutputAsync(string workingDirectory, params string[] arguments) - { - string output = await RunGitAsync(workingDirectory, arguments); - return output.Trim(); - } - /// /// Used only by GitPropertiesTestWorkspace's macOS-only $TMPDIR symlink resolution (see its own remarks) - "pwd -P" is unrelated to git/dotnet, so this /// runs it directly rather than forcing that through RunGitAsync/RunDotnetAsync. diff --git a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs index 192785c662..bf304b3713 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs @@ -9,14 +9,12 @@ public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBas [Fact] public async Task Publish_IncludesGitProperties() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - string result = await ProcessRunner.RunDotnetAsync(testApp, "publish"); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + string result = await repository.TestApp.PublishAsync(); result.Should().NotContain("duplicate"); - Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + 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/PublishNoBuildIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs index c22199e32f..04c63cc3f3 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs @@ -9,16 +9,13 @@ public sealed class PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuild [Fact] public async Task Publish_NoBuild_IncludesGitProperties() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunDotnetAsync(testApp, "build", "-c", "Release"); - - string publishResult = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-c", "Release", "--no-build"); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + await repository.TestApp.BuildAsync("-c", "Release"); + string publishResult = await repository.TestApp.PublishAsync("-c", "Release", "--no-build"); publishResult.Should().NotContain("duplicate"); - Dictionary properties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + 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..784fbf8758 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.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; + +/// +/// The result of - a directory tree with no ".git" anywhere in its ancestry, the way a source-based `cf push` +/// actually delivers a project. Deliberately not a itself: nothing here is a git repository, so none of that type's +/// git-invoking members would make sense on it. +/// +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/ShallowCloneInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs index 836c1b1cd2..478bf39809 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs @@ -14,14 +14,9 @@ public sealed class ShallowCloneInfoWhenEnableWarningsFalseTest : GitPropertiesB [Fact] public async Task ShallowClone_InfoWhenEnableWarningsFalse() { - string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 1); - - string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); - await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(shallow); - - string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - AssertReportedAsInfoOnly(result, "GITPROPS006", "repository is a shallow clone"); + GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 1); + GitRepository shallow = await source.CloneAsShallowAsync("shallow"); + string result = await shallow.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + result.AssertReportedAsInfoOnly("GITPROPS006", "repository is a shallow clone"); } } diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs index 8f4c36f8da..4006255db6 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -9,25 +9,18 @@ public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuild [Fact] public async Task ShallowClone_LeavesCommitCountsEmpty() { - string source = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "source"), 3); - await ProcessRunner.RunGitAsync(source, "tag", "v1.0.0"); - - string shallow = Path.Combine(Workspace.RootDirectory, "shallow"); - // --no-local is required here: for a plain local filesystem path, git's local-clone - // optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, - // producing a full clone that would make this test worthless. - await ProcessRunner.RunGitAsync(Path.GetTempPath(), "clone", "--quiet", "--no-local", "--depth", "1", source, shallow); - string isShallowRepository = await ProcessRunner.GetGitOutputAsync(shallow, "rev-parse", "--is-shallow-repository"); + 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"); - string testApp = await TestProjectWriter.CopyCurrentProjectFilesAsync(shallow); - - string result = await ProcessRunner.RunDotnetAsync(testApp, "build"); + string result = await shallow.TestApp.BuildAsync(); result.Should().NotContain("GITPROPS001"); result.Should().NotContain("GITPROPS002"); - AssertWarned(result, "GITPROPS006"); + result.AssertWarned("GITPROPS006"); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + 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/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs index f5820bf4d8..1f0b3803cb 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs @@ -15,14 +15,13 @@ public sealed class SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageRef [Fact] public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: dependency.ToProjectReferenceXml()); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GenerateGitProperties=false"); - AssertNoGitPropertiesGenerated(testApp); + await testApp.BuildAsync("-p:GenerateGitProperties=false"); + testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs index 22dc9e69e2..63fd001d8a 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -15,17 +15,16 @@ public sealed class SmartDefaultGeneratesGitPropertiesWhenConsumingPackageRefere [Fact] public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: dependency.ToProjectReferenceXml()); - await ProcessRunner.RunDotnetAsync(testApp, "build"); + await testApp.BuildAsync(); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + 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/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs index 747a364a8e..7d99c5735d 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs @@ -13,17 +13,18 @@ public sealed class SmartDefaultOverrideDetectsCustomPackageIdsTest : GitPropert [Fact] public async Task SmartDefault_Override_DetectsCustomPackageIds() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string customPackageId = "Contoso.Actuators"; - await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, customPackageId); + const string customPackageId = "Example.Package.Name"; - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync(customPackageId); - await ProcessRunner.RunDotnetAsync(testApp, "build", $"-p:GitPropertiesConsumingPackageIds={customPackageId}"); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: dependency.ToProjectReferenceXml()); - Dictionary properties = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + 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/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs index 130c61ee9c..44773357ac 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs @@ -14,14 +14,16 @@ public sealed class SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest : GitP [Fact] public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); + const string shortPackageId = "Some"; const string longerPackageId = "Some2"; - await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, longerPackageId); - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync(longerPackageId); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds=Some"); - AssertNoGitPropertiesGenerated(testApp); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: dependency.ToProjectReferenceXml()); + + await testApp.BuildAsync($"-p:GitPropertiesConsumingPackageIds={shortPackageId}"); + testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs index f772f8116a..5c8ac60409 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs @@ -16,14 +16,13 @@ public sealed class SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGen [Fact] public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - const string consumingPackageStandInName = "Steeltoe.Management.Endpoint"; - await TestProjectWriter.WriteDummyDependencyProjectAsync(repository, consumingPackageStandInName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: $""""""); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, + extraItemGroupContent: dependency.ToProjectReferenceXml()); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesConsumingPackageIds="); - AssertNoGitPropertiesGenerated(testApp); + await testApp.BuildAsync("-p:GitPropertiesConsumingPackageIds="); + testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs index c8c87b0cf9..dde9275d28 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -15,12 +15,12 @@ public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTe [Fact] public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1); - string testApp = await TestProjectWriter.WriteAppProjectAsync(repository, GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); + TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); - string result = await ProcessRunner.RunDotnetAsync(testApp, "build", "-v:detailed"); + string result = await testApp.BuildAsync("-v:detailed"); // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). result.Should().Contain("git.properties generation skipped: no reference to"); - AssertNoGitPropertiesGenerated(testApp); + testApp.GitPropertiesGenerated.Should().BeFalse(); } } 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..c5c249b488 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/TestProject.cs @@ -0,0 +1,104 @@ +// 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; + +/// +/// A single project directory under test (TestApp, ProjectA, a multi-targeted app, a dummy dependency, a pushed copy, a PackageReference consumer...). +/// Wraps the directory and project name so a test can build/publish it and read whatever git.properties it produced without re-deriving any of those +/// paths itself. Created by or - never directly. +/// +internal sealed class TestProject(string rootDirectory, string name) +{ + private readonly string _debugGitPropertiesFilePath = Path.Combine(rootDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + + private readonly string _releasePublishGitPropertiesFilePath = + Path.Combine(rootDirectory, "bin", "Release", TestPaths.TestAppTargetFramework, "publish", "git.properties"); + + 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", TestPaths.TestAppTargetFramework, $"{Name}.dll")); + + /// + /// The relative XML this project's directory/name pair would need to be referenced as a <ProjectReference> from a sibling project - used by the + /// smart-default detection tests to wire a stand-in into the app under test. + /// + public string ToProjectReferenceXml() + { + return $""""""; + } + + public Task BuildAsync(params string[] arguments) + { + return RunDotnetAsync("build", arguments); + } + + public Task PublishAsync(params string[] arguments) + { + return RunDotnetAsync("publish", arguments); + } + + public Task PublishAsync(int exitCodeExpected, params string[] arguments) + { + return RunDotnetAsync(exitCodeExpected, "publish", arguments); + } + + public Task RestoreAsync(params string[] arguments) + { + return RunDotnetAsync("restore", arguments); + } + + private Task RunDotnetAsync(string command, params string[] arguments) + { + return ProcessRunner.RunDotnetAsync(RootDirectory, [ + command, + .. arguments + ]); + } + + private Task RunDotnetAsync(int exitCodeExpected, string command, params string[] arguments) + { + return ProcessRunner.RunDotnetAsync(RootDirectory, exitCodeExpected, [ + command, + .. arguments + ]); + } + + public Task> ReadDebugPropertiesAsync() + { + return PropertiesFile.ReadAsync(_debugGitPropertiesFilePath); + } + + public Task> ReadReleasePublishPropertiesAsync() + { + return PropertiesFile.ReadAsync(_releasePublishGitPropertiesFilePath); + } + + public Task> ReadFallbackPropertiesAsync() + { + return PropertiesFile.ReadAsync(FallbackFilePath); + } + + /// + /// Reads the per-target-framework "bin\Debug\<tfm>\git.properties" produced by a multi-targeted build of this project - see + /// . + /// + 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 index dc16590717..30a8efbeec 100644 --- a/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs +++ b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs @@ -63,7 +63,7 @@ private static async Task CopySharedBuildInfrastructureAsync(string repoRootDest /// semicolon-separated list instead (see ). /// /// - /// The directory to write the project under - typically a repository root returned by + /// The directory to write the project under - typically a repository root returned by /// . /// /// diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs index 399957cb7f..bce09b7b98 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs @@ -13,19 +13,17 @@ public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCom [Fact] public async Task WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback file should have been written next to the .csproj."); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); - Dictionary properties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - string expectedCommitId = await ProcessRunner.GetGitOutputAsync(repository, "rev-parse", "HEAD"); + Dictionary properties = await repository.TestApp.ReadFallbackPropertiesAsync(); + string expectedCommitId = await repository.GetCommitIdAsync(); properties["git.commit.id"].Should().Be(expectedCommitId); // "bin\Debug\\publish" gets created as empty, routine SDK scaffolding even here (PrepareForPublish's own setup) - checking for the absence // of the compiled assembly itself, not just the bin directory, is what actually proves no compilation happened. - File.Exists(Path.Combine(testApp, "bin", "Debug", TestPaths.TestAppTargetFramework, $"{GitPropertiesTestWorkspace.TestAppProjectName}.dll")).Should() - .BeFalse("this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); + repository.TestApp.CompiledAssemblyExists.Should().BeFalse( + "this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs index 937f70f52a..25db23232f 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs @@ -15,14 +15,16 @@ public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : [Fact] public async Task WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); + // Confirms the actual premise of this test: nothing was compiled, so there is genuinely nothing for + // "dotnet publish --no-build" below to publish. + repository.TestApp.CompiledAssemblyExists.Should().BeFalse(); // 1, not just "nonzero": MSBuild's own long-standing, stable convention for "the build failed" (verified // against a real "dotnet publish --no-build" in this exact no-compiled-output scenario) - checked here, // at the point of the call, rather than via a separate assertion afterward. - await ProcessRunner.RunDotnetAsync(testApp, 1, "publish", "--no-build"); + await repository.TestApp.PublishAsync(1, "--no-build"); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs index 66c607080e..5f60104e55 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -17,21 +17,17 @@ public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublish [Fact] public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 2, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2, true); + await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); - await ProcessRunner.RunDotnetAsync(testApp, "build", "-t:WriteGitPropertiesFallbackFile"); - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); + RemotePushProjectTree remote = repository.SimulatePush("pushed"); + remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback git.properties must have survived the simulated push."); - string destinationDirectory = Path.Combine(Workspace.RootDirectory, "pushed"); - string pushedRoot = SyntheticGitRepositoryBuilder.SimulateSourcePush(repository, destinationDirectory); - string pushedApp = Path.Combine(pushedRoot, GitPropertiesTestWorkspace.TestAppProjectName); - File.Exists(GetFallbackFilePath(pushedApp)).Should().BeTrue("the fallback git.properties must have survived the simulated push."); - - string publishResult = await ProcessRunner.RunDotnetAsync(pushedApp, "publish", "-v:detailed"); + string publishResult = await remote.TestApp.PublishAsync("-v:detailed"); publishResult.Should().Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(pushedApp)); + Dictionary publishedProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs index 88cd583fc2..d622e2ed6f 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs @@ -13,12 +13,9 @@ public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPr [Fact] public async Task WriteGitPropertiesFallbackFile_WorksWithNoRestore() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - await ProcessRunner.RunDotnetAsync(testApp, "restore"); - await ProcessRunner.RunDotnetAsync(testApp, "build", "--no-restore", "-t:WriteGitPropertiesFallbackFile"); - - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue(); + 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/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs index e441fbb8c7..79c4ec2ec6 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs @@ -9,27 +9,25 @@ public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitP [Fact] public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - string result1 = await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + string result1 = await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); result1.Should().Contain( - $"git.properties: writing fallback copy to '{GetFallbackFilePath(testApp)}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + $"git.properties: writing fallback copy to '{repository.TestApp.FallbackFilePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj."); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback file should have been written next to the .csproj."); - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - Dictionary outputProperties1 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); + Dictionary outputProperties1 = await repository.TestApp.ReadDebugPropertiesAsync(); fallbackProperties.Should().BeEquivalentTo(outputProperties1, "the fallback file must carry the exact same content as the live build output."); - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + bool isDirty = await repository.IsDirtyAsync(); + isDirty.Should().BeFalse("the fallback file is gitignored, so it must not show up as an untracked change."); // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. - await ProcessRunner.RunDotnetAsync(testApp, "build", "-p:GitPropertiesWriteToProjectDirectory=true"); + await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); - Dictionary outputProperties2 = await PropertiesFile.ReadAsync(GetDebugGitPropertiesFilePath(testApp)); + Dictionary outputProperties2 = await repository.TestApp.ReadDebugPropertiesAsync(); outputProperties2["git.dirty"].Should().Be("false", "the gitignored fallback file from the first build must not make a later build see the tree as dirty."); diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs index 4dc9afd845..eb42b78125 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs @@ -16,21 +16,18 @@ public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : Gi [Fact] public async Task WriteToProjectDirectory_CreatesFallbackFile_OnPublish() { - string repository = await Workspace.CreateSyntheticRepoAsync(Path.Combine(Workspace.RootDirectory, "repo"), 1, true); - string testApp = Path.Combine(repository, GitPropertiesTestWorkspace.TestAppProjectName); - - string result = await ProcessRunner.RunDotnetAsync(testApp, "publish", "-p:GitPropertiesWriteToProjectDirectory=true", - "-p:GitPropertiesEnableWarnings=true"); - + GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); + string result = await repository.TestApp.PublishAsync("-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); result.Should().NotContain("GITPROPS0", "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); - File.Exists(GetFallbackFilePath(testApp)).Should().BeTrue("the fallback file should have been written next to the .csproj, even for a bare publish."); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue( + "the fallback file should have been written next to the .csproj, even for a bare publish."); - Dictionary fallbackProperties = await PropertiesFile.ReadAsync(GetFallbackFilePath(testApp)); - Dictionary publishedProperties = await PropertiesFile.ReadAsync(GetReleasePublishGitPropertiesFilePath(testApp)); + Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); + Dictionary publishedProperties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); fallbackProperties.Should().BeEquivalentTo(publishedProperties, "the fallback file must carry the exact same content as the published output."); - string gitStatus = await ProcessRunner.GetGitOutputAsync(repository, "status", "--porcelain"); - gitStatus.Should().BeEmpty("the fallback file is gitignored, so it must not show up as an untracked change."); + bool isDirty = await repository.IsDirtyAsync(); + isDirty.Should().BeFalse("the fallback file is gitignored, so it must not show up as an untracked change."); } } From 17e6997de95576635e435796ff3747f00ef729c9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:15:25 +0200 Subject: [PATCH 13/20] Simplify tests more --- ...ChangesAcrossBuildsUnlikeCommitTimeTest.cs | 2 +- .../EmptyGitRepository.cs | 5 +++ ...backFileIgnoredWhenLiveGitAvailableTest.cs | 2 +- .../FallbackFileUsedWhenNoGitAvailableTest.cs | 2 +- ...itignoreMakesLaterBuildsAppearDirtyTest.cs | 2 +- ...GitExecutableNotFoundWarnsByDefaultTest.cs | 2 +- .../GitFileWarnsByDefaultTest.cs | 2 +- .../GitPropertiesTestWorkspace.cs | 3 +- .../GitProperties.Build.Test/GitRepository.cs | 11 +++++++ .../GitRepositoryBuilder.cs | 31 ++++++++++++++----- .../GroundTruthAllPropertiesMatchGitTest.cs | 2 +- ...talBuildCacheSkipsButDirtyStaysLiveTest.cs | 2 +- .../MultiProjectSharesCacheTest.cs | 2 +- ...ctSharesCacheAcrossTargetFrameworksTest.cs | 2 +- .../MultipleRemotesOnlyOriginUrlIsUsedTest.cs | 2 +- .../NewTagInvalidatesCacheTest.cs | 2 +- .../NoCommitsWarnsByDefaultTest.cs | 2 +- .../NoGitInfoWhenEnableWarningsFalseTest.cs | 2 +- .../NoGitWarnsByDefaultTest.cs | 2 +- .../NonAsciiCommitDataRendersCorrectlyTest.cs | 5 ++- ...kageReferenceGeneratesGitPropertiesTest.cs | 2 +- .../PublishIncludesGitPropertiesTest.cs | 2 +- ...PublishNoBuildIncludesGitPropertiesTest.cs | 2 +- ...lowCloneInfoWhenEnableWarningsFalseTest.cs | 2 +- ...ShallowCloneLeavesCommitCountsEmptyTest.cs | 2 +- ...erDetectedConsumingPackageReferenceTest.cs | 6 ++-- ...rtiesWhenConsumingPackageReferencedTest.cs | 7 ++--- ...aultOverrideDetectsCustomPackageIdsTest.cs | 7 ++--- ...errideDoesNotMatchPackageIdAsPrefixTest.cs | 7 ++--- ...alPropertySkipsGenerationGracefullyTest.cs | 7 ++--- ...tionWhenNoConsumingPackageReferenceTest.cs | 2 +- ...roducesFallbackFileWithoutCompilingTest.cs | 2 +- ...FallbackFileThenPublishNoBuildFailsTest.cs | 2 +- ...henSimulatedPushServerPublishUsesItTest.cs | 2 +- ...rtiesFallbackFileWorksWithNoRestoreTest.cs | 2 +- ...DirectoryCreatesFallbackFileOnBuildTest.cs | 2 +- ...rectoryCreatesFallbackFileOnPublishTest.cs | 2 +- 37 files changed, 80 insertions(+), 63 deletions(-) diff --git a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs index 1dc850274a..42e307bade 100644 --- a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs +++ b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs @@ -13,7 +13,7 @@ public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitProper /// the field: telling you when THIS build actually ran. /// [Fact] - public async Task BuildTime_ChangesAcrossBuilds_UnlikeCommitTime() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs index c3173ab471..6a9cbc6a46 100644 --- a/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs +++ b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs @@ -19,6 +19,11 @@ 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); + } + /// /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source into this repository and writes the default TestApp project referencing it - see /// . Deliberately does not commit anything: this is meant for the "fully custom setup" diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs index 19b75de5e3..4ba73ec970 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs @@ -11,7 +11,7 @@ public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesB /// a last resort, never preferred over a real, currently-usable .git repository. /// [Fact] - public async Task FallbackFile_Ignored_WhenLiveGitAvailable() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs index 7e7dbc65cd..13bd0d467e 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs @@ -13,7 +13,7 @@ public sealed class FallbackFileUsedWhenNoGitAvailableTest : GitPropertiesBuildT /// along in the pushed source tree and get picked up, ending up in the published output exactly as if it had been generated live. /// [Fact] - public async Task FallbackFile_UsedWhenNoGitAvailable() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2, true); await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs index e08d84090a..36a126159b 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs @@ -13,7 +13,7 @@ public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest /// the actually-tracked source changed in between. /// [Fact] - public async Task FallbackFile_WithoutGitignore_MakesLaterBuildsAppearDirty() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); diff --git a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs index 3d34b3d261..ec2c8a3724 100644 --- a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs @@ -15,7 +15,7 @@ public sealed class GitExecutableNotFoundWarnsByDefaultTest : GitPropertiesBuild /// $(GitExecutable) at a name that can never resolve on any platform's PATH reliably reproduces "could not run git at all" through a real build. /// [Fact] - public async Task GitExecutableNotFound_WarnsByDefault() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); string defaultResult = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}"); diff --git a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs index dcaf8968ba..8300fdbd76 100644 --- a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class GitFileWarnsByDefaultTest : GitPropertiesBuildTestBase { [Fact] - public async Task GitFile_WarnsByDefault() + public async Task Test() { string projectDirectory = Workspace.GetPath("test-project"); TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index 9d03333362..7fb6fca340 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -125,8 +125,7 @@ public async Task CreateProjectDirectoryAsync(string name) public async Task CreateEmptyRepositoryAsync(string name) { string directory = GetPath(name); - Directory.CreateDirectory(directory); - await ProcessRunner.RunGitAsync(directory, "init", "--quiet", "--initial-branch=main", "."); + await GitRepositoryBuilder.InitializeEmptyAsync(directory); return new EmptyGitRepository(this, directory); } diff --git a/src/Management/test/GitProperties.Build.Test/GitRepository.cs b/src/Management/test/GitProperties.Build.Test/GitRepository.cs index b6c1cdef17..876b5eabfb 100644 --- a/src/Management/test/GitProperties.Build.Test/GitRepository.cs +++ b/src/Management/test/GitProperties.Build.Test/GitRepository.cs @@ -53,6 +53,17 @@ public async Task AddDependencyProjectAsync(string name) return new TestProject(projectDirectory, name); } + /// + /// Adds the default TestApp project referencing via a normal <ProjectReference> (see + /// ), with $(GenerateGitProperties) left unset so the smart default applies - the shared setup every + /// SmartDefault*Test in this suite needs before overriding $(GitPropertiesConsumingPackageIds) or $(GenerateGitProperties) itself. + /// + public Task AddTestAppReferencingAsync(TestProject dependency) + { + string extraItemGroupContent = dependency.ToProjectReferenceXml(); + return AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: extraItemGroupContent); + } + /// /// A bare console app consuming Steeltoe.Management.GitProperties.Build via <PackageReference> - see /// . Placed inside this repository (not directly under the workspace root), so the diff --git a/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs index 5c36f9387f..5c09027972 100644 --- a/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs +++ b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs @@ -19,6 +19,16 @@ internal static class GitRepositoryBuilder "obj" }; + /// + /// `git init` only - no config/.gitignore/history. Shared by and + /// , so the two never drift out of sync with each other. + /// + public static async Task InitializeEmptyAsync(string destination) + { + Directory.CreateDirectory(destination); + await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); + } + /// /// `git init` plus a handful of manufactured commits under . Deliberately stops short of committing anything beyond those /// manufactured files - see for the project-files-copy and final commit step this @@ -36,8 +46,7 @@ internal static class GitRepositoryBuilder /// public static async Task InitializeAsync(string destination, int commitCount, bool gitignoreFallbackFile) { - Directory.CreateDirectory(destination); - await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); + await InitializeEmptyAsync(destination); await ProcessRunner.RunGitAsync(destination, "config", "user.name", "Test User"); await ProcessRunner.RunGitAsync(destination, "config", "user.email", "test@example.com"); @@ -62,19 +71,27 @@ public static async Task InitializeAsync(string destination, int commitCount, bo await File.WriteAllTextAsync(Path.Combine(destination, $"file{commitNumber}.txt"), $"content {commitNumber}", TestContext.Current.CancellationToken); - await ProcessRunner.RunGitAsync(destination, "add", "-A"); - await ProcessRunner.RunGitAsync(destination, "commit", "--quiet", "-m", $"Commit {commitNumber}"); + await CommitAllAsync(destination, $"Commit {commitNumber}"); } } /// /// A small "git add -A / git commit" primitive - used by to commit the project files - /// it copies in after runs. + /// it copies in after runs, and by for a fully custom commit. + /// , when given, becomes a second "-m" argument - git's own subject/body convention for a single commit, not a second commit. /// - public static async Task CommitAllAsync(string repositoryDirectory, string message) + public static async Task CommitAllAsync(string repositoryDirectory, string subject, string? body = null) { await ProcessRunner.RunGitAsync(repositoryDirectory, "add", "-A"); - await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", message); + + if (body == null) + { + await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", subject); + } + else + { + await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", subject, "-m", body); + } } /// diff --git a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs index d533d10732..cf9302f8e2 100644 --- a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs @@ -41,7 +41,7 @@ public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTes /// verbosity. /// [Fact] - public async Task GroundTruth_AllPropertiesMatchGit() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 3); string result = await repository.TestApp.BuildAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs index bfca75423b..03b018a498 100644 --- a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs +++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropertiesBuildTestBase { [Fact] - public async Task IncrementalBuild_CacheSkipsButDirtyStaysLive() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs index c92a30da9a..823f6ee334 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase { [Fact] - public async Task MultiProject_SharesCache() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2); diff --git a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs index 10fec007f3..5b315586fe 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs @@ -15,7 +15,7 @@ public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : /// reflect this commit" freshness check would wrongly skip regenerating it. /// [Fact] - public async Task MultiTargetedProject_SharesCacheAcrossTargetFrameworks() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); TestProject testApp = await repository.AddProjectAsync("MultiTargetApp", TestPaths.MultiTargetTestFrameworks); diff --git a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs index ba75f50539..795774a2e2 100644 --- a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs @@ -16,7 +16,7 @@ public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildT /// this same test, also folds in coverage for an scp-style URL - the other shape StripUserInfo has to handle safely. /// [Fact] - public async Task MultipleRemotes_OnlyOriginUrlIsUsed() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.RunGitAsync("remote", "add", "upstream", "https://example.com/upstream.git"); diff --git a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs index 60a7f11ab4..cdc2bc5736 100644 --- a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs @@ -14,7 +14,7 @@ public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's own remarks). /// [Fact] - public async Task NewTag_InvalidatesCache() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs index 1cc0146f8d..e38cb021f9 100644 --- a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs @@ -11,7 +11,7 @@ public sealed class NoCommitsWarnsByDefaultTest : GitPropertiesBuildTestBase /// this state, which GenerateGitPropertiesCacheTask.Preflight treats as a routine, forgivable precondition rather than an unexpected failure. /// [Fact] - public async Task NoCommits_WarnsByDefault() + public async Task Test() { EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); GitRepository repository = await emptyRepository.AddTestAppAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs index f679d9b261..df04f12f85 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NoGitInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase { [Fact] - public async Task NoGit_InfoWhenEnableWarningsFalse() + public async Task Test() { TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); string result = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); diff --git a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs index a5609625ef..e51619a2a4 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NoGitWarnsByDefaultTest : GitPropertiesBuildTestBase { [Fact] - public async Task NoGit_WarnsByDefault() + public async Task Test() { TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); string result = await testApp.BuildAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs index 53122dfe9b..e2b1506505 100644 --- a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -13,7 +13,7 @@ public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildT /// desynchronize the line-based "git.<key>=<value>" format by spanning more than one physical line. /// [Fact] - public async Task NonAscii_CommitDataRendersCorrectly() + public async Task Test() { EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); @@ -27,8 +27,7 @@ public async Task NonAscii_CommitDataRendersCorrectly() await emptyRepository.RunGitAsync("config", "user.email", "test@example.com"); await File.WriteAllTextAsync(Path.Combine(emptyRepository.RootDirectory, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); await File.WriteAllTextAsync(Path.Combine(emptyRepository.RootDirectory, "file.txt"), "content", TestContext.Current.CancellationToken); - await emptyRepository.RunGitAsync("add", "-A"); - await emptyRepository.RunGitAsync("commit", "--quiet", "-m", nonAsciiCommitSubject, "-m", commitBody); + await emptyRepository.CommitAllAsync(nonAsciiCommitSubject, commitBody); GitRepository repository = await emptyRepository.AddTestAppAsync(); await repository.TestApp.BuildAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs index 2d305f5f6b..f04f503ebf 100644 --- a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -18,7 +18,7 @@ public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertie /// stale result from - the machine-wide global-packages cache at %userprofile%\.nuget\packages. /// [Fact] - public async Task NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs index bf304b3713..c85be5bb2f 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBase { [Fact] - public async Task Publish_IncludesGitProperties() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); string result = await repository.TestApp.PublishAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs index 04c63cc3f3..d7932ddad0 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuildTestBase { [Fact] - public async Task Publish_NoBuild_IncludesGitProperties() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync("-c", "Release"); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs index 478bf39809..5328bf1595 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs @@ -12,7 +12,7 @@ public sealed class ShallowCloneInfoWhenEnableWarningsFalseTest : GitPropertiesB /// way it does for the others. /// [Fact] - public async Task ShallowClone_InfoWhenEnableWarningsFalse() + public async Task Test() { GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 1); GitRepository shallow = await source.CloneAsShallowAsync("shallow"); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs index 4006255db6..02b3adaeca 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuildTestBase { [Fact] - public async Task ShallowClone_LeavesCommitCountsEmpty() + public async Task Test() { GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 3); await source.TagAsync("v1.0.0"); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs index 1f0b3803cb..d4cd1ceef8 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs @@ -13,13 +13,11 @@ public sealed class SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageRef /// the consumer explicitly opted out anyway. /// [Fact] - public async Task SmartDefault_ExplicitFalse_WinsOverDetectedConsumingPackageReference() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); - - TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: dependency.ToProjectReferenceXml()); + 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/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs index 63fd001d8a..e9f63c6e34 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -13,14 +13,11 @@ public sealed class SmartDefaultGeneratesGitPropertiesWhenConsumingPackageRefere /// this test stays fast and fully offline. /// [Fact] - public async Task SmartDefault_GeneratesGitProperties_WhenConsumingPackageReferenced() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); - - TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: dependency.ToProjectReferenceXml()); - + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); await testApp.BuildAsync(); Dictionary properties = await testApp.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs index 7d99c5735d..7c702cf6ac 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs @@ -11,16 +11,13 @@ public sealed class SmartDefaultOverrideDetectsCustomPackageIdsTest : GitPropert /// (e.g. a hand-rolled /info endpoint reading git.properties directly), so the smart default isn't hardcoded away from them. /// [Fact] - public async Task SmartDefault_Override_DetectsCustomPackageIds() + 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.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: dependency.ToProjectReferenceXml()); - + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); await testApp.BuildAsync($"-p:GitPropertiesConsumingPackageIds={customPackageId}"); Dictionary properties = await testApp.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs index 44773357ac..f90e2d358a 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs @@ -12,17 +12,14 @@ public sealed class SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest : GitP /// though "Some2" starts with "Some". Proves DetectConsumingPackageReferenceTask compares whole package IDs, not prefixes. /// [Fact] - public async Task SmartDefault_Override_DoesNotMatchPackageIdAsPrefix() + 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.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: dependency.ToProjectReferenceXml()); - + 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/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs index 5c8ac60409..69c7b9e7f7 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs @@ -14,14 +14,11 @@ public sealed class SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGen /// i.e. skip generation gracefully rather than fail the build with MSB4044. /// [Fact] - public async Task SmartDefault_Override_EmptyPackageIdsViaGlobalProperty_SkipsGenerationGracefully() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint"); - - TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, - extraItemGroupContent: dependency.ToProjectReferenceXml()); - + TestProject testApp = await repository.AddTestAppReferencingAsync(dependency); await testApp.BuildAsync("-p:GitPropertiesConsumingPackageIds="); testApp.GitPropertiesGenerated.Should().BeFalse(); } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs index dde9275d28..88e97c4f5e 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -13,7 +13,7 @@ public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTe /// the skip. /// [Fact] - public async Task SmartDefault_SkipsGeneration_WhenNoConsumingPackageReference() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs index bce09b7b98..3e99f22002 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs @@ -11,7 +11,7 @@ public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCom /// fallback file, and that doing so never compiles anything (the whole reason to prefer it over a full "dotnet build" before a source push). /// [Fact] - public async Task WriteGitPropertiesFallbackFile_ProducesFallbackFile_WithoutCompiling() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs index 25db23232f..995fefeaa9 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs @@ -13,7 +13,7 @@ public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : /// succeeding) - a signal to revisit the target, not just delete this test. /// [Fact] - public async Task WriteGitPropertiesFallbackFile_ThenPublishNoBuild_Fails() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs index 5f60104e55..838414082d 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -15,7 +15,7 @@ public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublish /// , simulate a source-based `cf push`, and confirm the server-side publish still picks it up correctly. /// [Fact] - public async Task WriteGitPropertiesFallbackFile_ThenSimulatedPush_ServerPublishUsesIt() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2, true); await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs index d622e2ed6f..14090d786f 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs @@ -11,7 +11,7 @@ public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPr /// once, same as a normal build. /// [Fact] - public async Task WriteGitPropertiesFallbackFile_WorksWithNoRestore() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); await repository.TestApp.RestoreAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs index 79c4ec2ec6..98155cdfe8 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs @@ -7,7 +7,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitPropertiesBuildTestBase { [Fact] - public async Task WriteToProjectDirectory_CreatesFallbackFile_OnBuild() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); string result1 = await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs index eb42b78125..6573902762 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs @@ -14,7 +14,7 @@ public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : Gi /// is available here, nothing should be skipped (and no GITPROPS0xx code should appear) regardless of that setting. /// [Fact] - public async Task WriteToProjectDirectory_CreatesFallbackFile_OnPublish() + public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); string result = await repository.TestApp.PublishAsync("-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); From 477d456d7a664798257bbd92eafb8cc8d98dc49c Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:36:31 +0200 Subject: [PATCH 14/20] Cleanup comments --- shared-package.props | 6 +- .../src/GitProperties.Build/AtomicFile.cs | 33 +- .../ComposeGitPropertiesTask.cs | 34 +- .../DetectConsumingPackageReferenceTask.cs | 40 +-- .../FindGitRepositoryRootTask.cs | 12 +- .../GenerateGitPropertiesCacheTask.cs | 196 +++-------- .../GitProperties.Build/GitOutputParser.cs | 14 +- .../GitPropertiesFormat.cs | 8 +- .../GitProperties.Build/SourceCheckout.txt | 13 +- ...ltoe.Management.GitProperties.Build.csproj | 76 +--- ...toe.Management.GitProperties.Build.targets | 332 ++++-------------- .../GitProperties.Build.Test/ProcessRunner.cs | 196 ++++------- ...Management.GitProperties.Build.Test.csproj | 15 +- 13 files changed, 252 insertions(+), 723 deletions(-) diff --git a/shared-package.props b/shared-package.props index 0e27df4609..77b91f2ab5 100644 --- a/shared-package.props +++ b/shared-package.props @@ -51,7 +51,11 @@ PackagePath="" /> - + + true + + + diff --git a/src/Management/src/GitProperties.Build/AtomicFile.cs b/src/Management/src/GitProperties.Build/AtomicFile.cs index e10fa00710..05770fbeaa 100644 --- a/src/Management/src/GitProperties.Build/AtomicFile.cs +++ b/src/Management/src/GitProperties.Build/AtomicFile.cs @@ -10,19 +10,13 @@ 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 multiple target frameworks of the same project - concurrently by default. When several of those share a single -/// file on disk, two problems follow: a reader can open the file at the exact moment a writer is replacing its content, and two writers can try to -/// update the same file at once. Plain file I/O handles neither case reliably, so every method here is built to tolerate both: writes go through a swap -/// that never leaves the file half-written, reads and writes both 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 instead of every one of them racing to produce the same result. +/// 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 { - /// - /// How many times a failed read or write is retried, and how long to wait between attempts. - /// private const int MaxAttempts = 10; - private static readonly TimeSpan ReadWriteRetryDelay = TimeSpan.FromMilliseconds(100); private static readonly TimeSpan AcquireLockRetryDelay = TimeSpan.FromMilliseconds(50); @@ -32,10 +26,10 @@ internal static class AtomicFile #pragma warning restore S6354 // Use a testable date/time provider /// - /// Writes the given lines to so that anyone reading it - even concurrently - 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. + /// 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 WriteAtomic(string path, List lines) + public static void Write(string path, List lines) { string? directory = Path.GetDirectoryName(path); @@ -67,10 +61,9 @@ public static void WriteAtomic(string path, List lines) } /// - /// Reads all lines from , retrying briefly if another process is momentarily in the way - the read-side counterpart to - /// . + /// Reads all lines from , retrying briefly if another process is momentarily in the way. /// - public static string[] ReadAllLinesWithRetry(string path) + public static string[] Read(string path) { Exception? lastError = null; @@ -90,9 +83,6 @@ public static string[] ReadAllLinesWithRetry(string path) throw new IOException($"Failed to read {path} after {MaxAttempts} attempts.", lastError); } - /// - /// Moves a freshly-written file into place, whether or not something is already there. - /// private static void MoveOrReplace(string sourcePath, string destinationPath) { try @@ -106,10 +96,9 @@ private static void MoveOrReplace(string sourcePath, string destinationPath) } /// - /// Attempts to become the sole holder of across every process trying to acquire it, for up to - /// . Returns null if that doesn't happen in time, or if locking isn't possible at all in this environment - holding this lock - /// is only ever an optimization (letting one process do some expensive work while everyone else waits and reuses the result, instead of every process - /// redoing it independently), never a correctness requirement, so callers must always have a safe fallback for when it can't be acquired. + /// 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) { diff --git a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs index f812015b90..413378bab1 100644 --- a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -9,12 +9,10 @@ namespace Steeltoe.Management.GitProperties.Build; /// -/// Merges the shared cache (see ) with the fields that can never be cached across projects: the live -/// working-tree dirty state, the per-project $(Version), and the current build's own timestamp. Runs once per project, every build - unlike the cache -/// generation, this is deliberately not skippable via Inputs/Outputs, since 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 by a second build. Every failure below returns false: this task's -/// own output (and the fallback copy, if configured) must never end up mismatched with what's actually on disk, so a partial run must fail the build -/// loudly rather than let a later step (the copy-to-output/publish targets) pick up stale or incomplete content. +/// Merges the shared git.properties cache with the fields that can never be cached: the live working-tree 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. Every failure returns false, so a partial run can't leave +/// output mismatched with what's on disk for a later step to pick up. /// // ReSharper disable once UnusedType.Global public sealed class ComposeGitPropertiesTask : Task @@ -49,10 +47,8 @@ public sealed class ComposeGitPropertiesTask : Task public string? Version { get; set; } /// - /// Gets or sets an optional additional path to copy the same composed content to - the durable fallback file that - /// Steeltoe.Management.GitProperties.Build.targets' IncludeGitPropertiesInOutput target later falls back to 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 by default). Empty (the default) is a no-op; only non-empty when the - /// consumer opted into $(GitPropertiesWriteToProjectDirectory). + /// 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; } @@ -82,7 +78,7 @@ public override bool Execute() bool isDirty = stdout.Length > 0; List lines = []; - if (!TryRunFileOperation($"read {CacheFile}", () => lines = AtomicFile.ReadAllLinesWithRetry(CacheFile).ToList())) + if (!TryRunFileOperation($"read {CacheFile}", () => lines = AtomicFile.Read(CacheFile).ToList())) { return false; } @@ -98,17 +94,14 @@ public override bool Execute() lines.Add($"git.dirty={(isDirty ? "true" : "false")}"); lines.Add($"git.build.version={GitPropertiesFormat.EscapeLineBreaks(Version)}"); - // S6354 (use an injectable time provider): not practical here, for the same reason as - // AtomicFile.TryAcquireExclusiveLock - see that method's remarks. Matches the - // ISO-8601-with-offset style git itself uses for git.commit.time (%cI), rather than - // normalizing to UTC - this is "when this build ran, in the build machine's own local time", - // not a value that needs to compare directly against the commit's own timestamp. + // 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 (!TryRunFileOperation($"write {OutputFile}", () => AtomicFile.WriteAtomic(OutputFile, lines))) + if (!TryRunFileOperation($"write {OutputFile}", () => AtomicFile.Write(OutputFile, lines))) { return false; } @@ -118,14 +111,9 @@ public override bool Execute() return true; } - return TryRunFileOperation($"write fallback file {FallbackFile}", () => AtomicFile.WriteAtomic(FallbackFile, lines)); + return TryRunFileOperation($"write fallback file {FallbackFile}", () => AtomicFile.Write(FallbackFile, lines)); } - /// - /// Runs a file-system operation that must succeed for this task to succeed - the shape shared by every step below that isn't the initial git status - /// check (which has its own, dual failure modes - see ). Logs a build error and returns false if - /// throws. - /// private bool TryRunFileOperation(string description, Action action) { try diff --git a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs index d9d6ccf63a..bfaf43b55d 100644 --- a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs +++ b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs @@ -5,23 +5,21 @@ 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 smart-default -/// $(GenerateGitProperties) so the vast majority of projects in a large solution (class libraries, test projects, anything that can't possibly expose an -/// actuator) skip generation entirely, without requiring each of them to opt out individually. +/// Determines whether this project's own fully-resolved dependency graph includes any of . Used to smart-default 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 deliberately: NuGet flattens -/// the *entire* transitive graph - through both PackageReference and ProjectReference chains - into every consuming project's own assets file, the same -/// mechanism that already lets a shared base library's own dependencies "just work" for whoever references it, without redeclaring them. That means this -/// also correctly detects the common pattern of a shared library wrapping actuator registration on behalf of many host apps, as long as that library's -/// own reference isn't PrivateAssets="All" - which would also strip the actuator assembly from every host's own runtime output, breaking the feature -/// outright, so it's not a viable pattern for a library that actually activates actuators in the first place. The file is written by a prior, separate -/// restore pass (implicit or explicit) - never by a target within the current build - so there's no ordering dependency on any of our own targets/hooks: -/// it's either already on disk with the final, fully-resolved graph, or it doesn't exist yet (a fresh clone with no restore at all), in which case this -/// safely reports no match instead of failing the build. +/// 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 @@ -30,10 +28,11 @@ public sealed class DetectConsumingPackageReferenceTask : Task /// Gets or sets the semicolon-separated list of package IDs to look for. /// /// - /// Deliberately not [Required], unlike every other string parameter on tasks in this project: MSBuild's required-parameter check treats an empty string - /// the same as "not supplied" at all, which would turn $(GitPropertiesConsumingPackageIds) explicitly set to blank (e.g. via - /// "-p:GitPropertiesConsumingPackageIds=") into a build error instead of the well-defined, graceful "no package ID ever matches" outcome - /// already produces for it. + /// 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; @@ -73,20 +72,13 @@ public override bool Execute() return true; } - /// - /// A plain substring search for the quoted package ID plus a trailing slash - the shape every "libraries" entry key in project.assets.json takes - /// ("PackageId/Version") - rather than a full JSON parse. Deliberately not scoped to the "libraries" object specifically, and deliberately not guarding - /// against a configured ID that happens to collide with an unrelated NuGet content-folder name (e.g. "lib", "tools", "analyzers", which show up as path - /// prefixes inside every package's own file list): Steeltoe's own consumers are enterprise teams that namespace-qualify their packages (e.g. - /// "Contoso.Actuators"), so a configured ID colliding with a short, generic folder name isn't a realistic scenario worth the complexity of a bounded - /// parse to rule out. - /// 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; diff --git a/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs index 24116d0408..87c395d2ea 100644 --- a/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs +++ b/src/Management/src/GitProperties.Build/FindGitRepositoryRootTask.cs @@ -5,11 +5,15 @@ 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 - full worktree/submodule support is out of scope. +/// back via rather than treated as a match. /// // ReSharper disable once UnusedType.Global public sealed class FindGitRepositoryRootTask : Task @@ -36,7 +40,7 @@ public sealed class FindGitRepositoryRootTask : Task public override bool Execute() { string repositoryRoot = string.Empty; - bool unsupportedGitFile = false; + bool isUnsupportedGitFile = false; try { @@ -60,7 +64,7 @@ public override bool Execute() if (File.Exists(gitPath)) { - unsupportedGitFile = true; + isUnsupportedGitFile = true; break; } @@ -74,7 +78,7 @@ public override bool Execute() } RepositoryRoot = repositoryRoot; - IsUnsupportedGitFile = unsupportedGitFile; + IsUnsupportedGitFile = isUnsupportedGitFile; return true; } } diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index 7c32fe5029..adfd7fd716 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -5,62 +5,36 @@ 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 17 git.properties fields that are stable across the whole repository (everything except the live working-tree state, the per-project -/// build version, and the current build's own timestamp - see ) and writes them to a shared cache file. Meant to -/// run at most once per solution build: callers key their own Inputs/Outputs incrementality off this task's output so 49 of 50 -/// projects in a solution reuse it instead of re-invoking git. +/// 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. /// -/// -/// Forgivable, EnableWarnings-gated situations each get their own diagnostic code so they can be suppressed individually (e.g. via -/// $(MSBuildWarningsAsErrors)/$(NoWarn)) or all at once via EnableWarnings=false: GITPROPS001 (no usable git working tree - the other half of this code, -/// "no .git directory at all", is raised from Steeltoe.Management.GitProperties.Build.targets before this task ever runs), GITPROPS003 (git executable -/// not found/runnable), GITPROPS004 (installed git predates ), GITPROPS005 (repository has zero commits), GITPROPS006 (a -/// shallow clone - unlike the other four, generation still succeeds, just with two fields left empty). GITPROPS002 (".git" is a file, i.e. a -/// worktree/submodule) is also raised from the .targets file, since it's detected before this task runs. -/// // ReSharper disable once UnusedType.Global public sealed class GenerateGitPropertiesCacheTask : Task { - /// - /// The oldest git version this task is known to work against. Set by "rev-parse --is-shallow-repository" below (see - /// ) - the newest feature this task relies on, added in git 2.15.0 (released 2017-10-30). Every other git command - /// used here (e.g. "tag --points-at", the "%cI" strict-ISO-8601 pretty-format placeholder) requires an older version than that, so this one flag alone - /// determines the actual floor. - /// + private const string DiagnosticPrefix = "GITPROPS"; private static readonly Version MinimumGitVersion = new(2, 15, 0); /// - /// How long waits for the cross-process cache lock before giving up and generating the cache anyway (see - /// 's own remarks for why that fallback is always safe). Sized to comfortably cover a real, if slow, - /// cache generation - the ~10 sequential git calls that make up one full run of this method, even under heavy antivirus scanning or a large repository's - /// slower history-wide commands - without leaving every other concurrently-building project/TFM blocked for anywhere near as long if the holder is - /// actually stuck rather than just slow. + /// 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); - /// - /// The field separator ("%x1f", ASCII Unit Separator) used in 's own "--pretty=format:" string - a single-element - /// array only because requires one, not because there's more than one separator. - /// private static readonly char[] CommitLogFieldSeparator = [(char)0x1F]; - /// - /// Line separators for splitting raw git command output in - both are needed since git's own output uses "\n" - /// (see 's remarks), but a "git config" value or similar could still legitimately contain a literal "\r". - /// private static readonly char[] LineSeparators = [ '\r', '\n' ]; - /// - /// CI-provided environment variables consulted by when HEAD is detached - most CI systems check out a specific commit - /// rather than a branch, so "git rev-parse --abbrev-ref HEAD" alone reports "HEAD", not the branch a human would recognize. - /// private static readonly string[] BranchEnvironmentVariableNames = [ "GITHUB_HEAD_REF", @@ -119,14 +93,14 @@ public override bool Execute() try { - string? commitId = Preflight(); + string? commitId = TryGetGitCommitId(); if (commitId == null) { return true; } - return TryGenerateAndWriteCache(commitId); + return TryGenerate(commitId); } catch (Exception exception) { @@ -135,17 +109,6 @@ public override bool Execute() } } - /// - /// Checks the installed git against - deliberately the very first git invocation this task makes, before relying on any - /// command a genuinely old git might reject outright (e.g. "rev-parse --is-shallow-repository" in would fail - /// with "unknown option" on one). - /// - /// - /// The "too old"/"unparseable" paths are both untested against a real git binary: reliably faking a fully working-but-old (or malformed-version) git - /// installation across Windows/Linux/macOS in this suite's plain "spawn a real dotnet build" test style isn't practical. The git-not-runnable-at-all - /// case doesn't share that problem - pointing $(GitExecutable) at a name that can never resolve on any platform's PATH is enough - see - /// GitExecutableNotFoundWarnsByDefaultTest. - /// private GitVersionStatus CheckGitVersion() { string? output = GetGitVersion(); @@ -165,7 +128,7 @@ private GitVersionStatus CheckGitVersion() if (installedVersion < MinimumGitVersion) { - ReportDiagnostic("GITPROPS004", + ReportDiagnostic(4, $"git.properties generation skipped: installed git version {installedVersion} is older than the minimum supported version " + $"({MinimumGitVersion}). Upgrade git to resolve this."); @@ -175,12 +138,6 @@ private GitVersionStatus CheckGitVersion() return GitVersionStatus.Compatible; } - /// - /// Runs "git --version", reporting GITPROPS003 (forgivable) if git can't be invoked at all - either the process fails to start (a thrown exception - the - /// most likely reason git.properties would ever be used at all: it isn't installed, or isn't on PATH) or it starts and exits with a non-zero code. Both - /// are routine, anticipated outcomes here specifically, unlike every other git invocation in this class - reported with just the immediate reason, not a - /// full exception dump. Returns the raw output on success, or null in either failure case. - /// private string? GetGitVersion() { string output; @@ -192,31 +149,26 @@ private GitVersionStatus CheckGitVersion() } catch (Exception exception) { - ReportDiagnostic("GITPROPS003", $"git.properties generation skipped: could not run '{GitExecutable}' ({exception.Message})."); + ReportDiagnostic(3, $"git.properties generation skipped: could not run '{GitExecutable}' ({exception.Message})."); return null; } if (exitCode != 0) { - ReportDiagnostic("GITPROPS003", $"git.properties generation skipped: '{GitExecutable} --version' exited with code {exitCode}."); + ReportDiagnostic(3, $"git.properties generation skipped: '{GitExecutable} --version' exited with code {exitCode}."); return null; } return output; } - /// - /// Forgivable checks: the repository must be a usable work tree, and it must have at least one commit - git's own runnability and version were already - /// checked in , before this ever runs. Returns null when either check fails - the anomaly is already reported via - /// (not as a build error), and generation simply skips, per the class remarks. Returns the resolved commit ID otherwise. - /// - private string? Preflight() + private string? TryGetGitCommitId() { int exitCode = RunGit("rev-parse --is-inside-work-tree", out string stdout, out _); if (exitCode != 0 || stdout != "true") { - ReportDiagnostic("GITPROPS001", $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git working tree."); + ReportDiagnostic(1, $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git working tree."); return null; } @@ -224,59 +176,38 @@ private GitVersionStatus CheckGitVersion() if (exitCode != 0) { - ReportDiagnostic("GITPROPS005", "git.properties generation skipped: repository has no commits yet."); + ReportDiagnostic(5, "git.properties generation skipped: repository has no commits yet."); return null; } return stdout; } - /// - /// From here on, any failure is unexpected and fatal - git and the repository are already known-good (see ). - /// and the copy-to-output targets in Steeltoe.Management.GitProperties.Build.targets are gated on - /// merely existing, not on whether it's actually fresh - so a failure here must return false and stop the build, rather than - /// let those steps run against a stale cache left over from an earlier, successful run. - /// - /// - /// Wrapped in a cross-process lock (see ): MSBuild's own Inputs/Outputs staleness check (in - /// Steeltoe.Management.GitProperties.Build.targets) runs independently per project/TFM, with no coordination between them - so when multiple projects or - /// TFMs of the same multi-targeted project build concurrently (MSBuild's default), more than one can see the shared cache as stale and decide to invoke - /// this task at the same time, before either has written a fresh one. Deliberately does NOT try to validate whether the existing cache content is itself - /// still correct/up to date - that's the target-level Inputs/Outputs check's job, and it has already decided we need to run. The only thing checked here - /// is whether the file was rewritten by someone else WHILE this call was waiting for the lock: only then is skipping safe, because a concurrent write - /// during that narrow window must be reacting to the exact same staleness trigger, in the same build, against the same repository state. (An early, - /// wrong attempt at this compared the cache's stored commit ID to the current one - but tagging an existing commit, for example, invalidates the cache - /// without changing the commit ID, so that check silently skipped writes that were actually needed.) This is purely a "thundering herd" optimization, - /// not a correctness fix - already guarantees no reader ever observes a torn/partial file even without it, so - /// failing to acquire the lock (or an environment where locking isn't possible at all) safely falls back to doing the work anyway, same as before this - /// existed. - /// - private bool TryGenerateAndWriteCache(string commitId) + 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; - // The ".lock" file itself is deliberately never deleted, only closed (releasing the OS-level lock, not the - // file) - it lives next to $(GitPropertiesCacheFile) under obj\_GitProperties\, an already-gitignored, - // `dotnet clean`-swept intermediate directory, so leaving it there costs nothing. Deleting it after use - // would actually be less safe: a concurrent builder could open (or create) the very same path a moment - // later, and "delete, then someone else recreates the same path" is the classic TOCTOU race that breaks a - // file-based mutex's mutual exclusion guarantee - simplest to just never delete it and let every build reuse - // the same, already-existing lock file. + // 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) { - // Purely informational: TryAcquireExclusiveLock's own contract is that failing to acquire it is never fatal, so this proceeds to regenerate - // the cache anyway - same as it always has - but a build that hits this on every single run (rather than as an occasional, expected race) is - // worth being able to spot from the log alone, without attaching a debugger. - Log.LogMessage("git.properties: could not acquire the lock for '{0}' within {1} seconds - proceeding without it.", CacheFile, + 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); + "the lock. Skipping.", CacheFile); return true; } @@ -292,8 +223,8 @@ private bool TryGenerateAndWriteCache(string commitId) if (isShallow) { - ReportDiagnostic("GITPROPS006", - "git.properties: repository is a shallow clone - git.total.commit.count and git.closest.tag.commit.count will be left empty. Run " + + ReportDiagnostic(6, + "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)."); } @@ -345,7 +276,7 @@ private bool TryGenerateAndWriteCache(string commitId) try { - AtomicFile.WriteAtomic(CacheFile, lines); + AtomicFile.Write(CacheFile, lines); } catch (Exception exception) { @@ -356,10 +287,6 @@ private bool TryGenerateAndWriteCache(string commitId) return true; } - /// - /// See the "Deliberately does NOT try to validate..." remarks on for why this checks only for a rewrite during - /// the lock wait, not the cache's own correctness. - /// private bool WasCacheRewrittenWhileWaitingForLock(DateTime? writeTimeBeforeLock) { if (!File.Exists(CacheFile)) @@ -385,10 +312,6 @@ private bool WasCacheRewrittenWhileWaitingForLock(DateTime? writeTimeBeforeLock) logFields.Length > 4 ? logFields[4] : string.Empty, logFields.Length > 5 ? logFields[5] : string.Empty); } - /// - /// Runs "describe --tags --long --always" and hands its output to . Failure here is not fatal - it - /// degrades to empty/fallback values, same as "no tags exist". - /// private TagDescription DescribeClosestTag(bool isShallow) { int exitCode = RunGit("describe --tags --long --always", out string stdout, out _); @@ -396,7 +319,7 @@ private TagDescription DescribeClosestTag(bool isShallow) if (isShallow) { - // Ancestry walk is truncated on a shallow clone - a "count" here would be silently wrong. + // 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); } @@ -437,9 +360,6 @@ private TagDescription DescribeClosestTag(bool isShallow) return GitOutputParser.ParseConfig(stdout); } - /// - /// Falls back to common CI environment variables when HEAD is detached (e.g. most CI checkouts). - /// private string ResolveBranch() { string branch = string.Empty; @@ -452,6 +372,7 @@ private string ResolveBranch() if (string.IsNullOrEmpty(branch) || branch == "HEAD") { + // Fallback to common CI environment variables when HEAD is detached (e.g. most CI checkouts). foreach (string name in BranchEnvironmentVariableNames) { string? value = Environment.GetEnvironmentVariable(name); @@ -472,12 +393,6 @@ private int RunGit(string arguments, out string stdout, out string stderr) return GitProcessRunner.Run(GitExecutable, RepositoryRoot, arguments, out stdout, out stderr); } - /// - /// Runs a git command whose failure is unexpected and fatal for cache generation - the shape shared by every call in - /// that isn't allowed to degrade gracefully (unlike, say, or - /// , which fall back to empty values instead). Logs a build error and returns false on a non-zero exit code; - /// is only meaningful when this returns true. - /// private bool TryRunGit(string arguments, string description, out string stdout) { int exitCode = RunGit(arguments, out stdout, out string stderr); @@ -491,64 +406,42 @@ private bool TryRunGit(string arguments, string description, out string stdout) return true; } - /// - /// Reports a forgivable anomaly - either a full skip (GITPROPS001/003/004/005, where the caller has already decided to skip generation and return true) - /// or a degraded-but-successful outcome (GITPROPS006, where generation still proceeds) - as a warning when is true, or a - /// plain informational message otherwise. - /// - /// - /// The downgraded message carries no code at all: a code only has a purpose when something is suppressible (via $(NoWarn)/ $(MSBuildWarningsAsErrors)), - /// which only applies to warnings - attaching one to a plain message just to look consistent added no real value, and an earlier version of this method - /// that manually embedded "{code}: " in the message text on top of also passing it as the structured Log.LogMessage code parameter actually - /// double-printed it (the console logger already renders a message's code automatically when one is supplied). Left at LogMessage's own default - /// importance (Normal), not High: consumers who set EnableWarnings=false have said this is routine and don't want it in their default build output, but - /// it's still one "-v:normal" away if they want to check. - /// - private void ReportDiagnostic(string code, string message) + private void ReportDiagnostic(int code, string message) { if (EnableWarnings) { - Log.LogWarning(null, code, null, null, 0, 0, 0, 0, message); + string warningCode = $"{DiagnosticPrefix}{code:D3}"; + Log.LogWarning(null, warningCode, null, null, 0, 0, 0, 0, message); } else { + // Omit code, which is only useful to suppress warnings/errors. Log.LogMessage(message); } } /// - /// Whether the installed git is new enough to use, as determined by . + /// Indicates whether the installed git version can be used. /// private enum GitVersionStatus { /// - /// Git ran and its version satisfies - safe to proceed. + /// Git ran and satisfies the minimum required version constraint. It is safe to proceed. /// Compatible, /// - /// Git either couldn't be run at all (GITPROPS003) or is older than (GITPROPS004) - both forgivable, already reported - /// via , generation simply skips. + /// Git couldn't be run at all or is older than the minimum required version. Both are forgivable, so generation simply skips. /// Incompatible, /// - /// Git ran, but its "--version" output couldn't be parsed at all - a bug in 's own regex, or an - /// unrecognized version string shape, not a routine/anticipated condition like "compatible" or "incompatible". Unlike those two, there's no safe - /// conclusion to draw here - we genuinely don't know whether the installed git is usable - so this is reported as a hard error via Log.LogError and, - /// unlike every other skip in this class, stops the build rather than letting a stale cache (if one exists from an earlier run) get used regardless. + /// 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 } - /// - /// The fields read from a single "log -1 --pretty=format:..." call - see . - /// - /// - /// A plain class with a primary constructor, not a record: records need "init" accessors under the hood, which require - /// System.Runtime.CompilerServices.IsExternalInit - not present on netstandard2.0 (this project's TargetFramework - see its own csproj remarks for why) - /// and not worth polyfilling just for four small, write-once data holders. - /// private sealed class CommitLogEntry(string abbrevId, string authorName, string authorEmail, string commitTime, string shortMessage, string fullMessage) { public string AbbrevId { get; } = abbrevId; @@ -559,9 +452,6 @@ private sealed class CommitLogEntry(string abbrevId, string authorName, string a public string FullMessage { get; } = fullMessage; } - /// - /// The fields read from "tag --points-at HEAD" and (unless shallow) "rev-list --count HEAD" - see . - /// private sealed class TagsAndCommitCount(string tags, string totalCommitCount) { public string Tags { get; } = tags; diff --git a/src/Management/src/GitProperties.Build/GitOutputParser.cs b/src/Management/src/GitProperties.Build/GitOutputParser.cs index 8b0729f981..5e9becf1dc 100644 --- a/src/Management/src/GitProperties.Build/GitOutputParser.cs +++ b/src/Management/src/GitProperties.Build/GitOutputParser.cs @@ -10,13 +10,13 @@ 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. + /// 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. + /// Parses the leading major/minor/patch numbers out of "git --version" output. /// public static Version? ParseGitVersion(string output) { @@ -35,7 +35,7 @@ internal static class GitOutputParser } /// - /// Parses "describe --tags --long --always" output for its three possible shapes: exactly-on-tag ("tag-0-gsha"), N-commits-ahead ("tag-N-gsha"), and + /// 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) @@ -54,7 +54,7 @@ public static TagDescription ParseTagDescribe(string describeOutput) if (!hasTagPrefix) { - // No tags reachable at all - "--always" fallback is a bare abbreviated SHA. + // No tags reachable at all. The "--always" fallback is a bare abbreviated SHA. baseDescribe = describeOutput; } else @@ -69,7 +69,7 @@ public static TagDescription ParseTagDescribe(string describeOutput) } /// - /// Parses "config --list" output for the keys we care about, stripping any embedded credentials from the remote URL. + /// 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) { @@ -126,7 +126,7 @@ private static string StripUserInfo(string url) } catch (UriFormatException) { - // Not a parseable absolute URL (e.g. SCP-like "git@host:org/repo.git") - nothing to strip. + // Not a parseable absolute URL (e.g. SCP-like "git@host:org/repo.git"), so there is nothing to strip. } } diff --git a/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs b/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs index fd37fce301..1b0b63fd3c 100644 --- a/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs +++ b/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs @@ -9,16 +9,10 @@ namespace Steeltoe.Management.GitProperties.Build; /// internal static class GitPropertiesFormat { - /// - /// The git.properties key that writes and later searches for (to - /// append the "-dirty" suffix) - shared so the two can never silently drift out of sync with each other. - /// public const string CommitIdDescribeKey = "git.commit.id.describe"; /// - /// Collapses real newlines to a literal "\n" so a value can never span multiple physical lines (Steeltoe's GitInfoContributor reads this file with - /// File.ReadAllLinesAsync and would silently truncate a value at the first embedded newline otherwise). Colons are deliberately left unescaped - - /// Steeltoe only unescapes "\:" back to ":", so leaving real colons alone (timestamps, URLs) is what round-trips correctly. + /// 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) { diff --git a/src/Management/src/GitProperties.Build/SourceCheckout.txt b/src/Management/src/GitProperties.Build/SourceCheckout.txt index e96a3b74ce..19ece332cf 100644 --- a/src/Management/src/GitProperties.Build/SourceCheckout.txt +++ b/src/Management/src/GitProperties.Build/SourceCheckout.txt @@ -1,11 +1,8 @@ -This file exists so build\Steeltoe.Management.GitProperties.Build.targets 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 - see -$(GitPropertiesTaskHost) in that .targets file for why this distinction matters (in-process vs. -out-of-process task loading). +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 (see the item in -Steeltoe.Management.GitProperties.Build.csproj), so its absence at the equivalent location inside an installed -package is exactly what identifies "packaged" consumption. +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 index 46e5d52618..cc62a0ad60 100644 --- a/src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj +++ b/src/Management/src/GitProperties.Build/Steeltoe.Management.GitProperties.Build.csproj @@ -1,95 +1,51 @@ - - + - + netstandard2.0 Generates a Spring Boot-compatible git.properties file at build time via MSBuild props/targets/tasks. - git;git.properties;actuator;info;MSBuild + 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/build/Steeltoe.Management.GitProperties.Build.targets b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets index aff89255b8..214e633285 100644 --- a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -1,171 +1,57 @@ - auto - + false + $(MSBuildProjectDirectory)\git.properties + true Steeltoe.Management.Endpoint git 7 - - true - - false - - $(MSBuildProjectDirectory)\git.properties - - netstandard2.0 - - $(MSBuildThisFileDirectory)..\bin\tasks\$(GitPropertiesTasksTfm)\Steeltoe.Management.GitProperties.Build.dll - + + - true - false + + netstandard2.0 + $(MSBuildThisFileDirectory)..\bin\tasks\$(GitPropertiesTasksTfm)\Steeltoe.Management.GitProperties.Build.dll + true + false - - - - - - - + - - - - - - - - - - - + + + + + + $(IntermediateOutputPath)git.properties - <_GitPropertiesShouldGenerate>$(GenerateGitProperties) @@ -175,13 +61,6 @@ - @@ -195,68 +74,31 @@ $(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')" /> @@ -265,36 +107,19 @@ - - - - + @@ -303,10 +128,10 @@ - + @@ -317,15 +142,12 @@ - + - <_GitPropertiesFallbackFileTarget Condition="'$(GitPropertiesWriteToProjectDirectory)' == 'true'">$(GitPropertiesFallbackFile) @@ -333,11 +155,6 @@ - @@ -347,35 +164,12 @@ - + diff --git a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs index 0f87671b83..19a6010063 100644 --- a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -8,8 +8,7 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// -/// Runs external processes (git, dotnet) and captures stdout/stderr merged into a single string - tests assert on substrings (diagnostic codes, -/// "Skipping target", etc.) that can land on either stream. +/// Runs external processes and captures stdout/stderr merged into a single string. /// internal static class ProcessRunner { @@ -22,31 +21,63 @@ internal static class ProcessRunner ]; /// - /// Generous enough to comfortably cover the slowest single command this suite ever runs (a Release build plus NuGet pack, or a "dotnet build" against a - /// cold/isolated restore) even under heavy system load, while still turning a genuine hang - e.g. a lingering process holding the redirected output pipe - /// open, the exact failure mode 's own MSBUILDDISABLENODEREUSE setting guards against - into a fast, informative test failure - /// instead of blocking the whole suite indefinitely. + /// Generous enough to comfortably cover the slowest single command this test suite ever runs (a Release build plus NuGet pack, or a "dotnet build" + /// against a cold/isolated restore) even under heavy system 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); - /// - /// Started once, eagerly, the moment this class is first touched - every caller (RunGitAsync and, transitively, everything else here) awaits this same - /// Task instead of re-resolving "where git" on every single call, or blocking a thread synchronously on it. - /// private static readonly Task RealGitExecutableTask = ResolveGitExecutableAsync(); - /// - /// The single, shared place every process this suite ever spawns gets its exit code checked - required, not defaulted, specifically so every one of - /// RunGitAsync, RunDotnetAsync, and RunPwdAsync has to make its own success expectation explicit, rather than silently inheriting whatever the last - /// caller happened to pass. Private: every real caller in this project goes through one of those three named, purpose-specific wrappers instead of this - /// directly. A silently-ignored failure here is exactly what let a broken "git remote set-url" call (rejected by git itself, exit code 128, because the - /// remote already had multiple values) pass unnoticed until a much later, more confusing assertion failed on a stale property value instead. - /// + private static async Task ResolveGitExecutableAsync() + { + string output = await RunAsync(LocatorCommand, Path.GetTempPath(), 0, CancellationToken.None, "git"); + + string firstLine = output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? + throw new InvalidOperationException($"Could not resolve the location of git via '{LocatorCommand} git'."); + + return firstLine.Trim(); + } + + 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, cancellationToken, arguments); + return output.Trim(); + } + + public static Task RunDotnetAsync(string workingDirectory, params string[] arguments) + { + return RunDotnetAsync(workingDirectory, 0, arguments); + } + + public static Task RunDotnetAsync(string workingDirectory, int exitCodeExpected, params string[] arguments) + { + string[] dotnetArguments = + [ + .. arguments, + "-p:RunAnalyzers=false", + "-p:NuGetAudit=false" + ]; + + return RunAsync("dotnet", workingDirectory, exitCodeExpected, TestContext.Current.CancellationToken, dotnetArguments); + } + + public static Task RunPwdAsync(string workingDirectory) + { + return RunAsync("pwd", workingDirectory, 0, TestContext.Current.CancellationToken, "-P"); + } + private static async Task RunAsync(string fileName, string workingDirectory, int exitCodeExpected, CancellationToken cancellationToken, params string[] arguments) { var outputBuilder = new StringBuilder(); - object outputLock = new(); + Lock outputLock = new(); var startInfo = new ProcessStartInfo { @@ -65,13 +96,10 @@ private static async Task RunAsync(string fileName, string workingDirect startInfo.ArgumentList.Add(argument); } - // Without this, a spawned "dotnet build"/"publish" leaves a persistent MSBuild worker node - // running in the background for reuse by a later build (the SDK's default, off a dev - // machine with no CI environment variable set). That node inherits our redirected - // stdout/stderr pipe handles and keeps them open even after the process we launched here - // exits - so the read end never sees EOF, and awaiting exit below would otherwise block - // forever waiting for a pipe close that will never happen, even though the build already - // completed successfully. + // Without this, a spawned "dotnet build"/"publish" leaves a persistent MSBuild worker node running in the background for reuse by a later build + // (the SDK's default, off a dev machine with no CI environment variable set). That node inherits our redirected stdout/stderr pipe handles and + // keeps them open even after the process we launched here exits, so the read end never sees EOF, and awaiting exit below would otherwise block + // forever waiting for a pipe close that will never happen, even though the build already completed successfully. startInfo.EnvironmentVariables["MSBUILDDISABLENODEREUSE"] = "1"; using var process = new Process(); @@ -82,17 +110,11 @@ private static async Task RunAsync(string fileName, string workingDirect process.BeginOutputReadLine(); process.BeginErrorReadLine(); - // Linked, not just our own timeout: composes the caller's own cancellation (e.g. xUnit - // cancelling the test run via TestContext.Current.CancellationToken) with our internal - // "this must be a hung pipe" timeout, without one silently overriding the other. using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(ProcessExitTimeout); try { - // WaitForExitAsync, unlike the synchronous WaitForExit, never ties up a thread pool - // thread for the (up to) two minutes this can wait - it composes with the timeout purely - // through cancellation instead. await process.WaitForExitAsync(timeoutSource.Token); } catch (OperationCanceledException) @@ -101,14 +123,10 @@ private static async Task RunAsync(string fileName, string workingDirect if (cancellationToken.IsCancellationRequested) { - // The caller's own token fired, not our internal timeout - propagate that as-is - // rather than obscuring a genuine cancellation behind a misleading TimeoutException. throw; } - throw new TimeoutException( - $"'{fileName} {string.Join(' ', arguments)}' in '{workingDirectory}' did not exit within {ProcessExitTimeout} - probably a hung " + - "process holding the redirected output pipe open rather than the command itself still genuinely running."); + throw new TimeoutException($"'{fileName} {string.Join(' ', arguments)}' in '{workingDirectory}' did not exit within {ProcessExitTimeout}."); } string output = outputBuilder.ToString(); @@ -125,36 +143,21 @@ void AppendLine(string? line) return; } - // Deliberately a call-scoped lock, not a shared static one: many tests run processes - // concurrently (parallel test execution, each spawning further child processes such as - // MSBuild worker nodes or the git shim), and every process's stdout/stderr callback - // would otherwise contend for one single global lock. Under enough concurrent, - // high-volume output (e.g. "dotnet build -v:detailed"), that starves the thread pool - // and stalls the whole suite indefinitely - this lock only ever guards this one call's - // own StringBuilder. -#pragma warning disable S6507 +#pragma warning disable S6507 // Blocks should not be synchronized on local variables + // Deliberately a call-scoped lock, not a shared static one: many tests run processes concurrently, and every process's stdout/stderr callback + // would otherwise contend for one single global lock. Under enough concurrent, high-volume output (e.g. "dotnet build -v:detailed"), that starves + // the thread pool and stalls the whole suite indefinitely. This lock only ever guards this one call's own StringBuilder. lock (outputLock) -#pragma warning restore S6507 +#pragma warning restore S6507 // Blocks should not be synchronized on local variables { outputBuilder.AppendLine(line); } } } - /// - /// Fire-and-forget, not awaited: with - /// - /// entireProcessTree: true - /// - /// walks every process on the machine to find descendants by parent-PID/start-time matching, which measurably takes several seconds on a machine with a - /// typical number of processes running - blocking the cancellation/timeout path on that would make a cancelled test appear to hang for that same several - /// seconds even though the test itself already stopped waiting. Re-resolves the process by id, rather than closing over the original (disposed by - /// 's own "using") instance, since calling a method on an already-disposed instance from this background - /// task would throw. Best-effort only: a failure here (the process already exited, or its id got reused by an unrelated process) must never surface - /// anywhere, since nothing awaits this. - /// private static void KillEntireProcessTreeInBackground(int processId) { + // Fire-and-forget, so that pressing the Stop button in an IDE responds immediately. _ = Task.Run(() => { try @@ -168,83 +171,4 @@ private static void KillEntireProcessTreeInBackground(int processId) } }); } - - /// - /// Uses the currently-running test's own TestContext.Current.CancellationToken - the right default for every call site in this suite except one: see the - /// explicit-CancellationToken overload below for why TestPaths.ResolveRepositoryRootAsync can't use this one. - /// - public static Task RunGitAsync(string workingDirectory, params string[] arguments) - { - return RunGitAsync(workingDirectory, TestContext.Current.CancellationToken, arguments); - } - - /// - /// Explicit-CancellationToken overload, used only by TestPaths.ResolveRepositoryRootAsync's shared, fire-once resolution with CancellationToken.None - - /// see that method's own remarks (and 's, for the identical reasoning) for why a specific test's - /// TestContext.Current.CancellationToken would be wrong there. - /// - public static async Task RunGitAsync(string workingDirectory, CancellationToken cancellationToken, params string[] arguments) - { - string gitExecutable = await RealGitExecutableTask; - string output = await RunAsync(gitExecutable, workingDirectory, 0, cancellationToken, arguments); - return output.Trim(); - } - - /// - /// Defaults to expecting success (exit code 0), symmetric with - see the explicit-exit-code overload below - /// for the handful of tests that deliberately provoke a build/publish failure as the scenario under test. - /// - public static Task RunDotnetAsync(string workingDirectory, params string[] arguments) - { - return RunDotnetAsync(workingDirectory, 0, arguments); - } - - /// - /// For the rare test that provokes a specific dotnet build/publish failure as the very scenario under test (e.g. - /// WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest). - /// - public static Task RunDotnetAsync(string workingDirectory, int exitCodeExpected, params string[] arguments) - { - return RunAsync("dotnet", workingDirectory, exitCodeExpected, TestContext.Current.CancellationToken, BuildDotnetArguments(arguments)); - } - - /// - /// Always appends "-p:RunAnalyzers=false" and "-p:NuGetAudit=false" - measured to save only ~0.4-0.5s on a standalone build of the (tiny) task assembly, - /// and within run-to-run noise once folded into a real end-to-end TestApp build, but harmless either way here: no test in this suite asserts on analyzer - /// diagnostics or NuGet audit warnings, only on this project's own GITPROPS0xx codes and plain build success/failure. - /// - private static string[] BuildDotnetArguments(string[] arguments) - { - return - [ - .. arguments, - "-p:RunAnalyzers=false", - "-p:NuGetAudit=false" - ]; - } - - /// - /// Used only by GitPropertiesTestWorkspace's macOS-only $TMPDIR symlink resolution (see its own remarks) - "pwd -P" is unrelated to git/dotnet, so this - /// runs it directly rather than forcing that through RunGitAsync/RunDotnetAsync. - /// - public static Task RunPwdAsync(string workingDirectory) - { - return RunAsync("pwd", workingDirectory, 0, TestContext.Current.CancellationToken, "-P"); - } - - /// - /// Deliberately CancellationToken.None, not a specific test's TestContext.Current.CancellationToken: is a single, - /// process-wide resource shared by every test class running concurrently, resolved once by whichever test happens to touch this class first - tying that - /// one-time resolution to that particular (arbitrary, unrelated) test's cancellation would be wrong, since cancelling THAT test must not cancel - /// resolution for every OTHER test still waiting on the same shared Task. - /// - private static async Task ResolveGitExecutableAsync() - { - string output = await RunAsync(LocatorCommand, Path.GetTempPath(), 0, CancellationToken.None, "git"); - - string firstLine = output.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? - throw new InvalidOperationException($"Could not resolve the location of git via '{LocatorCommand} git'."); - - return firstLine.Trim(); - } } 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 index 152bfcbc3f..1a787b6410 100644 --- 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 @@ -1,19 +1,16 @@ - + - + net10.0 - + From cf83fe6736917b057a5f7b590c7a683445ed1ec7 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:23:02 +0200 Subject: [PATCH 15/20] Refactor and cleanup tests --- .../GenerateGitPropertiesCacheTask.cs | 6 +- .../BuildOutputAssertions.cs | 27 ----- ...ChangesAcrossBuildsUnlikeCommitTimeTest.cs | 21 +--- .../DotNetCommandOutput.cs | 7 ++ .../DotNetCommandOutputAssertions.cs | 47 ++++++++ .../DotNetCommandOutputExtensions.cs | 13 +++ .../EmptyGitRepository.cs | 13 +-- ...backFileIgnoredWhenLiveGitAvailableTest.cs | 13 +-- ...allbackFileIsUsedWhenNoGitAvailableTest.cs | 28 +++++ .../FallbackFileUsedWhenNoGitAvailableTest.cs | 34 ------ ...itignoreMakesLaterBuildsAppearDirtyTest.cs | 16 +-- .../GitDiagnosticIds.cs | 22 ++++ ...GitExecutableNotFoundWarnsByDefaultTest.cs | 18 ++-- .../GitFileWarnsByDefaultTest.cs | 32 ------ .../GitPropertiesBuildTestBase.cs | 15 +-- .../GitPropertiesTestWorkspace.cs | 100 ++++-------------- .../GitProperties.Build.Test/GitRepository.cs | 35 ++---- .../GitRepositoryBuilder.cs | 46 +------- .../GitWorktreeWarnsByDefaultTest.cs | 27 +++++ .../GroundTruthAllPropertiesMatchGitTest.cs | 43 ++------ ...talBuildCacheSkipsButDirtyStaysLiveTest.cs | 10 +- .../MultiProjectSharesCacheTest.cs | 23 +--- ...ctSharesCacheAcrossTargetFrameworksTest.cs | 10 +- .../MultipleRemotesOnlyOriginUrlIsUsedTest.cs | 28 +---- .../NewTagInvalidatesCacheTest.cs | 15 +-- .../NoCommitsWarnsByDefaultTest.cs | 16 ++- .../NoGitInfoWhenEnableWarningsFalseTest.cs | 17 --- .../NoGitRepositoryWarnsByDefaultTest.cs | 27 +++++ .../NoGitWarnsByDefaultTest.cs | 17 --- .../NonAsciiCommitDataRendersCorrectlyTest.cs | 22 +--- ...kageReferenceGeneratesGitPropertiesTest.cs | 28 ++--- .../GitProperties.Build.Test/ProcessRunner.cs | 22 ++-- .../PublishIncludesGitPropertiesTest.cs | 3 +- ...PublishNoBuildIncludesGitPropertiesTest.cs | 3 +- .../RemotePushProjectTree.cs | 5 - ...lowCloneInfoWhenEnableWarningsFalseTest.cs | 22 ---- ...ShallowCloneLeavesCommitCountsEmptyTest.cs | 8 +- .../ShallowCloneWarnsByDefaultTest.cs | 28 +++++ ...erDetectedConsumingPackageReferenceTest.cs | 6 -- ...rtiesWhenConsumingPackageReferencedTest.cs | 6 -- ...aultOverrideDetectsCustomPackageIdsTest.cs | 4 - ...errideDoesNotMatchPackageIdAsPrefixTest.cs | 5 - ...alPropertySkipsGenerationGracefullyTest.cs | 9 +- ...tionWhenNoConsumingPackageReferenceTest.cs | 12 +-- .../GitProperties.Build.Test/TestPaths.cs | 44 -------- .../GitProperties.Build.Test/TestProject.cs | 33 +++--- .../TestProjectWriter.cs | 100 ++---------------- ...roducesFallbackFileWithoutCompilingTest.cs | 12 +-- ...FallbackFileThenPublishNoBuildFailsTest.cs | 13 --- ...henSimulatedPushServerPublishUsesItTest.cs | 18 +--- ...rtiesFallbackFileWorksWithNoRestoreTest.cs | 4 - ...DirectoryCreatesFallbackFileOnBuildTest.cs | 16 ++- ...rectoryCreatesFallbackFileOnPublishTest.cs | 24 ++--- 53 files changed, 348 insertions(+), 825 deletions(-) delete mode 100644 src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs create mode 100644 src/Management/test/GitProperties.Build.Test/DotNetCommandOutput.cs create mode 100644 src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs create mode 100644 src/Management/test/GitProperties.Build.Test/DotNetCommandOutputExtensions.cs create mode 100644 src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs delete mode 100644 src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs create mode 100644 src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index adfd7fd716..b017ab96da 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -168,7 +168,7 @@ private GitVersionStatus CheckGitVersion() if (exitCode != 0 || stdout != "true") { - ReportDiagnostic(1, $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git working tree."); + ReportDiagnostic(1, $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git repository."); return null; } @@ -406,11 +406,11 @@ private bool TryRunGit(string arguments, string description, out string stdout) return true; } - private void ReportDiagnostic(int code, string message) + private void ReportDiagnostic(int diagnosticId, string message) { if (EnableWarnings) { - string warningCode = $"{DiagnosticPrefix}{code:D3}"; + string warningCode = $"{DiagnosticPrefix}{diagnosticId:D3}"; Log.LogWarning(null, warningCode, null, null, 0, 0, 0, 0, message); } else diff --git a/src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs b/src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs deleted file mode 100644 index 11e62cd5ff..0000000000 --- a/src/Management/test/GitProperties.Build.Test/BuildOutputAssertions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// 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; - -/// -/// Fluent assertions against the captured stdout/stderr of a "dotnet build"/"publish" call, for the two diagnostic-severity checks nearly every -/// warn-by-default test repeats. -/// -internal static class BuildOutputAssertions -{ - public static void AssertWarned(this string output, string code) - { - output.Should().Contain($"warning {code}"); - } - - /// - /// GitPropertiesEnableWarnings=false downgrades a diagnostic from a warning to a plain informational message - with no code at all (see - /// GenerateGitPropertiesCacheTask.ReportDiagnostic's remarks for why). - /// - public static void AssertReportedAsInfoOnly(this string output, string code, string messageSnippet) - { - output.Should().NotContain(code); - output.Should().Contain(messageSnippet); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs index 42e307bade..37f289cdaf 100644 --- a/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs +++ b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.cs @@ -6,12 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest : GitPropertiesBuildTestBase { - /// - /// Guards against git.build.time accidentally ending up in the shared, cross-project/cross-TFM cache (see GenerateGitPropertiesCacheTask) instead of - /// being recomputed by ComposeGitPropertiesTask on every build - same class of regression IncrementalBuildCacheSkipsButDirtyStaysLiveTest guards against - /// for git.dirty. A cached build time would go stale (reporting the FIRST build's time on every subsequent one), silently defeating the whole point of - /// the field: telling you when THIS build actually ran. - /// [Fact] public async Task Test() { @@ -19,21 +13,12 @@ public async Task Test() await repository.TestApp.BuildAsync(); Dictionary propertiesBefore = await repository.TestApp.ReadDebugPropertiesAsync(); - // git.build.time is formatted "yyyy-MM-ddTHH:mm:sszzz" (ComposeGitPropertiesTask) - second - // resolution only, no fractional part - so two builds landing within the same wall-clock - // second would produce identical values no matter how this test is written. This delay is - // sized to that format's own precision, not incidental slack. 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"], - "git.build.time must be recomputed on every build, not reused from the shared cache."); - - propertiesAfter["git.commit.time"].Should().Be(propertiesBefore["git.commit.time"], - "git.commit.time must stay tied to the (unchanged) commit, unlike git.build.time."); - - propertiesAfter["git.commit.id"].Should().Be(propertiesBefore["git.commit.id"], "nothing about the commit itself changed between the two builds."); + 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/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..6a467f6ecc --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs @@ -0,0 +1,47 @@ +// 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 code = FormatCode(diagnosticId); + Subject.Value.Should().Contain($"warning {code}"); + } + + [CustomAssertion] + public void NotContainGitWarning(GitDiagnosticId diagnosticId) + { + string code = FormatCode(diagnosticId); + Subject.Value.Should().NotContain($"warning {code}"); + } + + [CustomAssertion] + public void NotContainAnyGitWarnings() + { + Subject.Value.Should().NotContain(DiagnosticPrefix); + } + + [CustomAssertion] + public void ContainGitInfo(GitDiagnosticId diagnosticId, string messageSnippet) + { + string code = FormatCode(diagnosticId); + Subject.Value.Should().NotContain(code); + Subject.Value.Should().Contain(messageSnippet); + } + + 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 index 6a9cbc6a46..3ee074830d 100644 --- a/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs +++ b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.cs @@ -4,12 +4,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// A git repository directory with no TestApp (or any other project) written into it yet - just "git init", so a test can run fully custom git commands -/// (config, commits, tags...) before any project files exist, without those files ending up swept into whatever it commits along the way. Created by -/// - never directly. Call once ready to build -/// against it, which upgrades this into a full . -/// internal sealed class EmptyGitRepository(GitPropertiesTestWorkspace workspace, string rootDirectory) { public string RootDirectory { get; } = rootDirectory; @@ -24,13 +18,10 @@ public Task CommitAllAsync(string subject, string? body = null) return GitRepositoryBuilder.CommitAllAsync(RootDirectory, subject, body); } - /// - /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source into this repository and writes the default TestApp project referencing it - see - /// . Deliberately does not commit anything: this is meant for the "fully custom setup" - /// scenario where any commit is already the caller's own responsibility. - /// 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/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs index 4ba73ec970..bfb80a4933 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs @@ -6,20 +6,13 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class FallbackFileIgnoredWhenLiveGitAvailableTest : GitPropertiesBuildTestBase { - /// - /// Guards against a stale fallback file (left over from some earlier build) ever shadowing live generation - the fallback file must only ever be used as - /// a last resort, never preferred over a real, currently-usable .git repository. - /// [Fact] public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); - - await File.WriteAllLinesAsync(repository.TestApp.FallbackFilePath, ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"], - TestContext.Current.CancellationToken); - - string result = await repository.TestApp.BuildAsync("-v:detailed"); - result.Should().NotContain("using pre-generated fallback file", "the fallback notice must not appear when live generation actually ran."); + await Workspace.WriteFileAsync(repository.TestApp.FallbackFilePath, ["git.commit.id=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:detailed"); + output.Value.Should().NotContain("using pre-generated fallback file"); Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); string expectedCommitId = await repository.GetCommitIdAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs new file mode 100644 index 0000000000..459ab1c3ec --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/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; + +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:detailed"); + 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/FallbackFileUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs deleted file mode 100644 index 13bd0d467e..0000000000 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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; - -public sealed class FallbackFileUsedWhenNoGitAvailableTest : GitPropertiesBuildTestBase -{ - /// - /// End-to-end simulation of the scenario that motivated $(GitPropertiesWriteToProjectDirectory) in the first place: `cf push` using the - /// dotnet_core_buildpack directly from source, which strips ".git" from the pushed tree unconditionally (see SimulateSourcePush) - meaning live - /// generation can never run for that push, ever. A pre-generated fallback file (produced by an earlier LOCAL build, where .git was available) must ride - /// along in the pushed source tree and get picked up, ending up in the published output exactly as if it had been generated live. - /// - [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", "the gitignored fallback file must not make its own producing build see the tree as dirty."); - - RemotePushProjectTree remote = repository.SimulatePush("pushed"); - remote.HasGitDirectory.Should().BeFalse("the simulated push must not carry '.git' along, matching cf push's own default."); - remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback git.properties must have survived the simulated push."); - - string publishResult = await remote.TestApp.PublishAsync("-v:detailed"); - publishResult.Should().NotContain("GITPROPS001", "the fallback file should suppress the usual no-.git diagnostic entirely."); - publishResult.Should().Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); - - Dictionary publishedProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); - publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs index 36a126159b..e53559f090 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.cs @@ -6,26 +6,16 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest : GitPropertiesBuildTestBase { - /// - /// The negative counterpart to - proves the README's ".gitignore this file" warning - /// is describing a real consequence, not a hypothetical one: deliberately uses a repository WITHOUT the fallback file gitignored, so the file the first - /// build writes is left behind as a genuine untracked change - permanently flipping git.dirty to "true" on every later build, even though nothing about - /// the actually-tracked source changed in between. - /// [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("the freshly-written, ungitignored fallback file should show up as an untracked change."); + isDirty.Should().BeTrue(); await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); - - Dictionary properties2 = await repository.TestApp.ReadDebugPropertiesAsync(); - - properties2["git.dirty"].Should().Be("true", - "the ungitignored fallback file left over from the first build makes every later build see the tree as dirty."); + Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); + properties["git.dirty"].Should().Be("true"); } } diff --git a/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs b/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs new file mode 100644 index 0000000000..dfeadf61dd --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.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; + +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 int Value { get; } + + private GitDiagnosticId(int value) + { + Value = value; + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs index ec2c8a3724..81bd828b7c 100644 --- a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs @@ -8,26 +8,20 @@ public sealed class GitExecutableNotFoundWarnsByDefaultTest : GitPropertiesBuild { private const string BogusGitExecutable = "this-executable-definitely-does-not-exist-anywhere"; - /// - /// $(GitExecutable) itself failing to run - the most likely real-world reason git.properties would ever be skipped at all: git simply isn't installed, - /// or isn't on PATH - is otherwise untested (see GenerateGitPropertiesCacheTask.CheckGitVersion's own remarks on why the "too old"/"unparseable version - /// string" siblings of this same check can't reasonably be exercised this way). Unlike those two, this one needs no real-but-fake git binary: pointing - /// $(GitExecutable) at a name that can never resolve on any platform's PATH reliably reproduces "could not run git at all" through a real build. - /// [Fact] public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); - string defaultResult = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}"); - defaultResult.AssertWarned("GITPROPS003"); + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}"); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitExecutableNotFound); - string enableWarningsFalseResult = + DotNetCommandOutput disableWarningsOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); - enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS003", "could not run"); + disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitExecutableNotFound, "git.properties generation skipped: could not run"); - string featureOffResult = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GenerateGitProperties=false"); - featureOffResult.Should().NotContain("GITPROPS003"); + DotNetCommandOutput featureOffOutput = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitExecutableNotFound); repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); } diff --git a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs deleted file mode 100644 index 8300fdbd76..0000000000 --- a/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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; - -public sealed class GitFileWarnsByDefaultTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task Test() - { - string projectDirectory = Workspace.GetPath("test-project"); - TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); - - // ".git" must sit above BOTH TestApp and Steeltoe.Management.GitProperties.Build for the repo-root walk - // (which starts at TestApp, the project actually being built) to find it - i.e. at - // projectDirectory itself. - await File.WriteAllTextAsync(Path.Combine(projectDirectory, ".git"), "gitdir: /some/where/.git/worktrees/test-project", - TestContext.Current.CancellationToken); - - string defaultResult = await testApp.BuildAsync(); - defaultResult.AssertWarned("GITPROPS002"); - - string enableWarningsFalseResult = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); - enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS002", "resolves to a git worktree or submodule"); - - string featureOffResult = await testApp.BuildAsync("-p:GenerateGitProperties=false"); - featureOffResult.Should().NotContain("GITPROPS002"); - - testApp.GitPropertiesGenerated.Should().BeFalse(); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs index 610dc440ac..71486ba44f 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.cs @@ -5,19 +5,10 @@ namespace Steeltoe.Management.GitProperties.Build.Test; /// -/// Shared workspace lifecycle for every test in this project. Split one test per class - rather than many [Fact] methods on one shared class - -/// purely for parallelism: xUnit v3 runs different test classes concurrently by default (no configuration or version upgrade needed - verified -/// empirically), but never parallelizes methods within the SAME class. Since every test here is dominated by "dotnet build"/"publish" subprocess time -/// that's mostly I/O/wait-bound, not CPU-bound (measured: 4 concurrent builds complete in ~1.3x one build's time, 8 concurrent in ~1.9x), -/// one-class-per-test lets the whole suite's wall-clock approach its slowest single test instead of the sum of all of them. +/// 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 (xUnit only discovers public test classes), but is internal rather than protected: GitPropertiesTestWorkspace is -/// itself internal, and a protected member of a public class can't expose a less-accessible type in its signature (CS0051/CS0052). Internal still works -/// the same way for every derived class here, since they all live in this same assembly. Implements IAsyncLifetime (not a constructor) to create -/// : GitPropertiesTestWorkspace.CreateAsync itself awaits a "pwd -P" subprocess on macOS to resolve a symlink-free root path, -/// and a constructor can't await. -/// public abstract class GitPropertiesBuildTestBase : IAsyncLifetime { internal GitPropertiesTestWorkspace Workspace { get; private set; } = null!; diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index 7fb6fca340..fe7838ed6b 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -6,23 +6,8 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// An isolated temporary directory tree for a single test, cleaned up on Dispose - and the factory every test uses to create the -/// / instances its scenario needs, so no test has to combine a path under -/// itself. Deliberately avoids "gitprop" in its own name (in any casing) so a test's workspace path can never accidentally -/// satisfy an Assert.Contains/DoesNotContain check against a GITPROPS0xx diagnostic code in build output, which routinely echoes back the working -/// directory path. -/// -/// -/// Constructed via rather than a public constructor: resolving the physical (symlink-free) root directory on macOS needs a -/// "pwd -P" subprocess, and a constructor can't await one. -/// internal sealed class GitPropertiesTestWorkspace : IDisposable { - /// - /// The project name every dev-loop consumer test writes its own copy of Steeltoe.Management.GitProperties.Build against (see - /// ) - shared so callers never retype it. - /// public const string TestAppProjectName = "TestApp"; public string RootDirectory { get; } @@ -40,44 +25,29 @@ public static async Task CreateAsync() return new GitPropertiesTestWorkspace(physicalRootDirectory); } - /// - /// On macOS, $TMPDIR resolves through a symlink (/var -> /private/var) that the OS silently canonicalizes away whenever a spawned process (git, - /// dotnet, MSBuild) reports its own working directory - e.g. in "git.properties: writing..." diagnostic messages. Resolving once up front here keeps - /// every path-based assertion in these tests (which compares against exactly that reported text) consistent with what a spawned process itself reports, - /// instead of the un-resolved alias $TMPDIR itself returns. - /// private static async Task ResolvePhysicalPathAsync(string path) { - if (!OperatingSystem.IsMacOS()) + if (OperatingSystem.IsMacOS()) { - return path; + // On macOS, $TMPDIR resolves through a symlink (/var -> /private/var). + string output = await ProcessRunner.RunPwdAsync(path); + return output.Trim(); } - string output = await ProcessRunner.RunPwdAsync(path); - return output.Trim(); + return path; } public void Dispose() { - // Set GITPROPERTIES_KEEP_TEST_WORKSPACES=1 to inspect a workspace after the run instead of - // having it deleted here. - if (Environment.GetEnvironmentVariable("GITPROPERTIES_KEEP_TEST_WORKSPACES") == "1") - { - return; - } - try { - // git marks files under .git\objects (and packed clones) read-only on Windows, which - // makes a plain recursive delete throw UnauthorizedAccessException - PowerShell's - // Remove-Item -Force clears this automatically, but Directory.Delete does not. + // 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. + // Best-effort cleanup only: a transiently locked file (e.g. an antivirus scan) must not fail the test run. } } @@ -94,22 +64,12 @@ private static void ClearReadOnlyAttributes(DirectoryInfo directory) } } - /// - /// Combines a short, per-test name (e.g. "repo", "proj") with - the one remaining path-combining primitive every factory - /// method below (and the occasional test that needs a workspace-scoped scratch path with no object of its own, e.g. an isolated NuGet packages folder) - /// is built on. - /// public string GetPath(string name) { return Path.Combine(RootDirectory, name); } - /// - /// A directory with no git repository at all, containing just a copy of the CURRENT Steeltoe.Management.GitProperties.Build source and its default - /// TestApp - see . For tests that specifically cover the "no usable git repository above - /// this project" diagnostics. - /// - public async Task CreateProjectDirectoryAsync(string name) + public async Task CreateProjectWithoutGitAsync(string name) { string directory = GetPath(name); Directory.CreateDirectory(directory); @@ -117,11 +77,6 @@ public async Task CreateProjectDirectoryAsync(string name) return new TestProject(appDirectory, TestAppProjectName); } - /// - /// A freshly-initialized git repository with zero commits - "git init" only, no config/.gitignore/manufactured history. For tests that specifically - /// cover the "repository has no commits yet" diagnostic, and any scenario that wants full manual control over its own git commands before adding - /// projects (see ). - /// public async Task CreateEmptyRepositoryAsync(string name) { string directory = GetPath(name); @@ -129,35 +84,12 @@ public async Task CreateEmptyRepositoryAsync(string name) return new EmptyGitRepository(this, directory); } - /// - /// A brand-new, synthetic git repo with a controlled, minimal history (`git init` plus a handful of manufactured commits, via - /// ) plus the default TestApp - deliberately never a clone of this (large, real) repository, so the - /// suite stays fast. - /// - /// - /// The directory to initialize the repository in, relative to . - /// - /// - /// The number of manufactured commits to create before the project files are added. - /// - /// - /// Whether to also list "git.properties" in the repository's .gitignore, modeling the setup a real consumer of $(GitPropertiesWriteToProjectDirectory) - /// must follow - see for the complementary "not .cfignore'd" half of that same guidance. Defaults - /// to false so most tests (which never write a fallback file into the project directory at all) aren't given a gitignore entry they don't exercise - - /// only pass true for tests that specifically cover $(GitPropertiesWriteToProjectDirectory)/the fallback file, so a regression that accidentally wrote - /// one in a test that doesn't expect it still shows up as an untracked file (and, transitively, as git.dirty=true) instead of being silently absorbed by - /// a blanket ignore rule. - /// - public async Task CreateGitRepositoryAsync(string name, int commitCount, bool gitignoreFallbackFile = false) + public async Task CreateGitRepositoryAsync(string name, int commitCount, bool includeFallbackFileInGitignore = false) { string directory = GetPath(name); - await GitRepositoryBuilder.InitializeAsync(directory, commitCount, gitignoreFallbackFile); + await GitRepositoryBuilder.InitializeAsync(directory, commitCount, includeFallbackFileInGitignore); TestProject testApp = await GitRepository.WriteDefaultTestAppAsync(directory); var repository = new GitRepository(this, directory, testApp); - - // Commit the project files too, so the synthetic repo starts clean (git.dirty=false) - // unless a test deliberately makes a further change - otherwise every synthetic repo would - // show git.dirty=true purely because of these untracked-but-just-added files. await GitRepositoryBuilder.CommitAllAsync(directory, "Add project files"); return repository; } @@ -169,6 +101,16 @@ public Task PackGitPropertiesBuildToFeedAsync() public Task WriteIsolatedNuGetConfigAsync(TestProject project, string feedDirectory) { - return TestProjectWriter.WriteIsolatedNuGetConfigAsync(Path.Combine(project.RootDirectory, "nuget.config"), 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 index 876b5eabfb..3f5ef306ef 100644 --- a/src/Management/test/GitProperties.Build.Test/GitRepository.cs +++ b/src/Management/test/GitProperties.Build.Test/GitRepository.cs @@ -4,16 +4,10 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// A synthetic git repository directory under test, with its default already written into it. Wraps the directory so a test can -/// run git commands, add further projects, or simulate a source-based push against it without re-deriving its path or reaching for -/// / itself. Created by or -/// - never directly. For a repository that doesn't have (or need) a TestApp - a fully custom git setup -/// - see instead. -/// 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); @@ -53,22 +47,12 @@ public async Task AddDependencyProjectAsync(string name) return new TestProject(projectDirectory, name); } - /// - /// Adds the default TestApp project referencing via a normal <ProjectReference> (see - /// ), with $(GenerateGitProperties) left unset so the smart default applies - the shared setup every - /// SmartDefault*Test in this suite needs before overriding $(GitPropertiesConsumingPackageIds) or $(GenerateGitProperties) itself. - /// public Task AddTestAppReferencingAsync(TestProject dependency) { string extraItemGroupContent = dependency.ToProjectReferenceXml(); return AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null, extraItemGroupContent: extraItemGroupContent); } - /// - /// A bare console app consuming Steeltoe.Management.GitProperties.Build via <PackageReference> - see - /// . Placed inside this repository (not directly under the workspace root), so the - /// repo-root walk that starts at the consumer project still finds this repository's own ".git" above it. - /// public async Task AddPackageConsumerProjectAsync(string name, string packageVersion) { string projectDirectory = Path.Combine(rootDirectory, name); @@ -76,21 +60,21 @@ public async Task AddPackageConsumerProjectAsync(string name, strin return new TestProject(projectDirectory, name); } - /// - /// A shallow (--depth) clone of this repository, with the CURRENT Steeltoe.Management.GitProperties.Build source and a fresh TestApp copied in - see - /// . --no-local is required here: for a plain local filesystem path, git's local-clone - /// optimization bypasses shallow-transfer logic entirely and --depth is silently ignored, producing a full clone that would make a shallow-clone test - /// worthless. - /// 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); @@ -102,11 +86,6 @@ public RemotePushProjectTree SimulatePush(string name) return new RemotePushProjectTree(pushRoot, pushedTestApp); } - /// - /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source into and writes the default TestApp project - /// referencing it - the one piece and (and, via - /// , the fully-custom-setup path) all share. - /// internal static async Task WriteDefaultTestAppAsync(string repositoryDirectory) { string appDirectory = await TestProjectWriter.CopyCurrentProjectFilesAsync(repositoryDirectory); diff --git a/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs index 5c09027972..bcceac448f 100644 --- a/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs +++ b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs @@ -4,12 +4,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// Builds synthetic git repositories with a controlled, minimal commit history for tests, and simulates the one deployment step (`cf push`) that strips -/// ".git" from what a running app actually sees on disk. Deliberately never operates on a clone of this (large, real) repository, so the suite stays -/// fast - see for the workspace-level entry point that also copies in the project -/// files under test. -/// internal static class GitRepositoryBuilder { private static readonly HashSet SimulatedPushExcludedDirectoryNames = new(StringComparer.OrdinalIgnoreCase) @@ -19,41 +13,19 @@ internal static class GitRepositoryBuilder "obj" }; - /// - /// `git init` only - no config/.gitignore/history. Shared by and - /// , so the two never drift out of sync with each other. - /// public static async Task InitializeEmptyAsync(string destination) { Directory.CreateDirectory(destination); await ProcessRunner.RunGitAsync(destination, "init", "--quiet", "--initial-branch=main", "."); } - /// - /// `git init` plus a handful of manufactured commits under . Deliberately stops short of committing anything beyond those - /// manufactured files - see for the project-files-copy and final commit step this - /// leaves to its caller. - /// - /// - /// The directory to initialize the repository in. - /// - /// - /// The number of manufactured commits to create. - /// - /// - /// Whether to also list "git.properties" in the repository's .gitignore - see for the - /// full explanation. - /// - public static async Task InitializeAsync(string destination, int commitCount, bool gitignoreFallbackFile) + 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"); - // Without this, dotnet build's own obj/bin output is untracked and git status correctly - // (but unhelpfully, for these tests) reports the tree as dirty - real projects always - // gitignore build output, same as this repo does. - string gitignoreContent = gitignoreFallbackFile + string gitignoreContent = includeFallbackFileInGitignore ? """ bin/ obj/ @@ -75,11 +47,6 @@ await File.WriteAllTextAsync(Path.Combine(destination, $"file{commitNumber}.txt" } } - /// - /// A small "git add -A / git commit" primitive - used by to commit the project files - /// it copies in after runs, and by for a fully custom commit. - /// , when given, becomes a second "-m" argument - git's own subject/body convention for a single commit, not a second commit. - /// public static async Task CommitAllAsync(string repositoryDirectory, string subject, string? body = null) { await ProcessRunner.RunGitAsync(repositoryDirectory, "add", "-A"); @@ -94,15 +61,6 @@ public static async Task CommitAllAsync(string repositoryDirectory, string subje } } - /// - /// Copies a directory tree to a brand-new location with no ".git" anywhere in its ancestry, simulating what actually reaches a running app when deployed - /// via `cf push` using the dotnet_core_buildpack from source: ".git" is excluded from the pushed tree unconditionally by `cf push` itself (a CLI-level - /// default, independent of ".cfignore" and not something the buildpack has any special handling for - verified against both tools' source), which is - /// exactly why live git.properties generation can never run server-side for that scenario. "bin"/"obj" are also excluded, mirroring the ".cfignore" - /// hygiene real projects need anyway (both to avoid pushing stale local build output, and because reusing another location's "obj" as-is would confuse - /// MSBuild's own incremental state, which embeds absolute paths). Anything else - including an already-generated fallback "git.properties" sitting next - /// to the ".csproj" - is copied as-is, exactly as it would ride along in the real push payload. - /// public static string SimulateSourcePush(string sourceDirectory, string destinationDirectory) { CopyDirectoryExcluding(new DirectoryInfo(sourceDirectory), destinationDirectory, SimulatedPushExcludedDirectoryNames); diff --git a/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs new file mode 100644 index 0000000000..2b944f7481 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.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; + +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); + + DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitWorktreeFound, "resolves to a git worktree or submodule"); + + 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/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs index cf9302f8e2..7edf390a70 100644 --- a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs @@ -6,51 +6,20 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -// Automated regression tests for the Steeltoe.Management.GitProperties.Build project -// (build/Steeltoe.Management.GitProperties.Build.targets plus its compiled MSBuild tasks). Exercises the -// scenarios that were manually verified while building this feature: ground-truth property values, -// incremental cache behavior, publish (with and without a prior build), the warn/info/skip diagnostic -// paths, shallow clones, non-ASCII commit data, and cross-project cache sharing. Every test runs against -// an isolated temporary workspace containing the CURRENT source of Steeltoe.Management.GitProperties.Build -// (see GitPropertiesTestWorkspace), not a stale git-tracked copy, so it always exercises whatever is on -// disk right now. Every git repository a test operates against is a small, synthetic one created from -// scratch (`git init` plus a handful of manufactured commits) - never a clone of this (large, real) -// repository - so the suite stays fast. Nothing here touches this repository's own working tree. -// -// One class per test (see GitPropertiesBuildTestBase's own remarks for why) rather than many [Fact] -// methods on one shared class: xUnit v3 runs different test classes concurrently by default, but never -// parallelizes methods within the same class. Every test here is dominated by "dotnet build"/"publish" -// subprocess time that's mostly I/O/wait-bound, not CPU-bound - splitting this way lets the whole suite's -// wall-clock approach its single slowest test instead of the sum of all of them. -// -// Measured (TRX per-test timing, sequential run): every test costs roughly 3.7-4.2 seconds PER "dotnet -// build"/"publish" subprocess it spawns, almost regardless of git-repository complexity - even the two -// tests with no git repository at all still cost ~3.7s each, matching the single-build cases with a real -// repository. Git setup (git init, commits, tags, config) is comparatively free by contrast. When a new -// scenario needs only one extra assertion against a plain default build, prefer folding it into an -// existing test that already builds one (see GroundTruthAllPropertiesMatchGitTest's own remarks for an -// example) over adding a dedicated test that pays for another subprocess just to re-run the identical -// setup. public sealed class GroundTruthAllPropertiesMatchGitTest : GitPropertiesBuildTestBase { - /// - /// Also folds in two other checks against this same build, rather than paying for a dedicated "dotnet build" subprocess (by far the dominant cost of any - /// test in this suite - see the class remarks) just to exercise a single extra assertion against an otherwise identical, plain default build: that the - /// fallback file is never written unless explicitly opted into (see WriteToProjectDirectoryCreatesFallbackFileOnBuildTest for the positive case, which - - /// unlike this one - genuinely needs its own build, since it passes a different property), and that writing git.properties is confirmed at default - /// verbosity. - /// + // 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); - string result = await repository.TestApp.BuildAsync(); + DotNetCommandOutput output = await repository.TestApp.BuildAsync(); - repository.TestApp.FallbackGitPropertiesGenerated.Should().BeFalse( - "the fallback file must not be written into the project directory unless explicitly opted into."); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeFalse(); string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - result.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + output.Value.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); @@ -81,7 +50,7 @@ public async Task Test() 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), "git.build.time must reflect roughly when this build actually ran."); + buildTime.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromMinutes(5)); string[] expectedKeys = [ diff --git a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs index 03b018a498..815e261d93 100644 --- a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs +++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs @@ -11,14 +11,10 @@ public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync(); - repository.SharedCacheExists.Should().BeTrue("the cache file should exist after first build."); + repository.SharedCacheExists.Should().BeTrue(); - string result2 = await repository.TestApp.BuildAsync("-v:detailed"); - - // "Skipping target" is itself the deterministic, sufficient proof that nothing rewrote the - // cache file on this second build - no last-write-time comparison (and the sleep it would - // otherwise need, to guarantee a detectably different timestamp) is needed on top of it. - result2.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:detailed"); + 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/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs index 823f6ee334..59006b449e 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs @@ -10,27 +10,14 @@ public sealed class MultiProjectSharesCacheTest : GitPropertiesBuildTestBase public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2); - - // Two independent projects at the repo root, each with their own ProjectReference/Import - // pointing at the SAME sibling Steeltoe.Management.GitProperties.Build copy (CreateSyntheticRepository - // already placed TestApp there; reuse that exact relative layout for ProjectA/ProjectB by placing them - // at the repo root too, siblings of TestApp and "src"). TestProject projectA = await repository.AddProjectAsync("ProjectA"); TestProject projectB = await repository.AddProjectAsync("ProjectB"); + DotNetCommandOutput outputA = await projectA.BuildAsync("-v:detailed"); + outputA.Value.Should().Contain("git.properties: generating shared cache"); + repository.SharedCacheExists.Should().BeTrue(); - string resultA = await projectA.BuildAsync("-v:detailed"); - - resultA.Should().Contain("git.properties: generating shared cache", - "ProjectA (first to build) should be the one that actually generates the shared cache."); - - repository.SharedCacheExists.Should().BeTrue("ProjectA's build should have generated the shared cache."); - - string resultB = await projectB.BuildAsync("-v:detailed"); - - // "did not log generating the cache" is itself the deterministic, sufficient proof ProjectB - // reused ProjectA's cache instead of rewriting it - no last-write-time comparison (and the - // sleep it would otherwise need) is needed on top of it. - resultB.Should().NotContain("git.properties: generating shared cache", "ProjectB should reuse ProjectA's cache instead of regenerating it."); + DotNetCommandOutput outputB = await projectB.BuildAsync("-v:detailed"); + outputB.Value.Should().NotContain("git.properties: generating shared cache"); Dictionary propertiesA = await projectA.ReadDebugPropertiesAsync(); Dictionary propertiesB = await projectB.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs index 5b315586fe..040ce4b870 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.cs @@ -6,14 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest : GitPropertiesBuildTestBase { - /// - /// A single multi-targeted project (current TFM plus the one immediately before it - see ) is a - /// different sharing scenario than : MSBuild builds a multi-targeted project's inner TFMs concurrently by - /// default (unlike the two sequential "dotnet build" invocations that test uses), which is exactly the race - /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's cross-process lock exists to handle. Also guards against the regression that fix's first - /// (wrong) attempt introduced: tagging the current commit invalidates the cache without changing the commit ID, so a naive "does the cache already - /// reflect this commit" freshness check would wrongly skip regenerating it. - /// [Fact] public async Task Test() { @@ -37,7 +29,7 @@ public async Task Test() foreach (Dictionary properties in propertiesAfter) { - properties["git.tags"].Should().Be("v1.0.0", "both target frameworks must observe the new tag, even though the commit it points at didn't change."); + properties["git.tags"].Should().Be("v1.0.0"); } } } diff --git a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs index 795774a2e2..497e1917d2 100644 --- a/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs @@ -6,15 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class MultipleRemotesOnlyOriginUrlIsUsedTest : GitPropertiesBuildTestBase { - /// - /// GenerateGitPropertiesCacheTask.ReadConfig only recognizes the literal "remote.origin.url" config key - a repository with additional remotes (a fork's - /// "upstream", a CI mirror, etc.) must still resolve git.remote.origin.url to origin's own URL, never another remote's. Also confirms that when origin - /// itself has more than one configured URL (via "git remote set-url --add"), the field resolves to the LAST one, matching "git config --list"'s own - /// last-value-wins behavior for repeated keys (verified independently against a real git binary before writing this test). The winning URL is - /// deliberately given embedded credentials, folding StripUserInfo's own coverage into this same build rather than spinning up a dedicated test just for - /// that: proves credentials are stripped from whichever URL actually wins, not just from a hypothetical single-remote case. A second build, later in - /// this same test, also folds in coverage for an scp-style URL - the other shape StripUserInfo has to handle safely. - /// [Fact] public async Task Test() { @@ -24,27 +15,12 @@ public async Task Test() 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"); - properties["git.remote.origin.url"].Should().Be("https://example.com/origin-second.git", - "origin's own last-configured URL must win, ignoring both the unrelated 'upstream' remote and origin's own first URL - and its embedded " + - "'user:pass@' credentials must be stripped before the value ever reaches the cache file."); - - // A non-absolute, scp-style URL (git's other common remote syntax, alongside plain HTTPS/SSH URLs) isn't - // something Uri can parse - proves StripUserInfo leaves it untouched rather than mangling or blanking it, - // since there's nothing safe for it to rewrite. Reuses this same repository/build (a second build, not a - // dedicated test) rather than paying for a whole new synthetic repo just to reconfigure one remote - only - // possible because .git\config is itself a tracked _GitPropertiesCacheInputs entry, so this reconfiguration - // alone (no new commit needed) is enough to force the cache to regenerate on the next build. Remove-then-add - // rather than a plain "set-url": origin already carries two URLs from above (via "remote add" + "set-url - // --add"), and git refuses a plain "set-url" against a remote with multiple values ("fatal: could not set - // 'remote.origin.url' ... has multiple values") - remove-then-add is the clean way to replace all of them - // with exactly one. 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", - "a non-absolute, scp-style remote URL must be left exactly as-is - there is nothing safe for StripUserInfo to rewrite."); + propertiesAfterScpStyleUrl["git.remote.origin.url"].Should().Be("git@github.com:org/repo.git"); } } diff --git a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs index cdc2bc5736..5306c3b408 100644 --- a/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.cs @@ -6,13 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NewTagInvalidatesCacheTest : GitPropertiesBuildTestBase { - /// - /// Also folds in coverage for the trickiest shape ParseDescribeOutput's dash-splitting has to get right - a tag name that itself contains a dash - /// ("release-1.0"), combined with a nonzero commits-ahead count - rather than spinning up a dedicated test just for that. Tagging an ANCESTOR of HEAD, - /// not HEAD itself, serves both purposes at once: it produces that nonzero count, and it keeps HEAD's own commit ID unchanged, which is the actual point - /// of this test's name - proving a new tag ref alone still invalidates the shared cache (see the regression this guards against in - /// GenerateGitPropertiesCacheTask.TryGenerateAndWriteCache's own remarks). - /// [Fact] public async Task Test() { @@ -23,17 +16,11 @@ public async Task Test() 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", "HEAD is exactly one commit ahead of the tagged ancestor."); - - // "release-1.0-1", not the raw "git describe" output ("release-1.0-1-g"): - // git.commit.id.describe deliberately omits the abbreviated SHA - see - // GenerateGitPropertiesCacheTask.ParseDescribeOutput's own BaseDescribe reconstruction. + 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/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs index e38cb021f9..df8f8413b1 100644 --- a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs @@ -6,24 +6,20 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NoCommitsWarnsByDefaultTest : GitPropertiesBuildTestBase { - /// - /// A freshly-initialized repository (real ".git", so GITPROPS001/002 don't fire instead) with zero commits yet - "git rev-parse HEAD" itself fails in - /// this state, which GenerateGitPropertiesCacheTask.Preflight treats as a routine, forgivable precondition rather than an unexpected failure. - /// [Fact] public async Task Test() { EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); GitRepository repository = await emptyRepository.AddTestAppAsync(); - string defaultResult = await repository.TestApp.BuildAsync(); - defaultResult.AssertWarned("GITPROPS005"); + DotNetCommandOutput defaultOutput = await repository.TestApp.BuildAsync(); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryHasNoCommits); - string enableWarningsFalseResult = await repository.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); - enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS005", "no commits yet"); + DotNetCommandOutput disableWarningsOutput = await repository.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitRepositoryHasNoCommits, "no commits yet"); - string featureOffResult = await repository.TestApp.BuildAsync("-p:GenerateGitProperties=false"); - featureOffResult.Should().NotContain("GITPROPS005"); + 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/NoGitInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs deleted file mode 100644 index df04f12f85..0000000000 --- a/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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; - -public sealed class NoGitInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task Test() - { - TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); - string result = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); - result.AssertReportedAsInfoOnly("GITPROPS001", "no usable .git directory found above"); - testApp.GitPropertiesGenerated.Should().BeFalse(); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.cs new file mode 100644 index 0000000000..74eb84689c --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.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; + +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); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitRepositoryNotFound, "no usable .git directory found above"); + testApp.GitPropertiesGenerated.Should().BeFalse(); + + DotNetCommandOutput featureOffOutput = await testApp.BuildAsync("-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryNotFound); + + testApp.GitPropertiesGenerated.Should().BeFalse(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs deleted file mode 100644 index e51619a2a4..0000000000 --- a/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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; - -public sealed class NoGitWarnsByDefaultTest : GitPropertiesBuildTestBase -{ - [Fact] - public async Task Test() - { - TestProject testApp = await Workspace.CreateProjectDirectoryAsync("test-project"); - string result = await testApp.BuildAsync(); - result.AssertWarned("GITPROPS001"); - testApp.GitPropertiesGenerated.Should().BeFalse(); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs index e2b1506505..3839e031eb 100644 --- a/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs @@ -6,37 +6,25 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NonAsciiCommitDataRendersCorrectlyTest : GitPropertiesBuildTestBase { - /// - /// Also folds in coverage for GitPropertiesFormat.EscapeLineBreaks, rather than paying for a dedicated build just to exercise it: a commit message with - /// a body (subject + blank line + body, as illustrated in PackageReadme.md's own example output) contains real embedded newlines in git's raw "%B" - /// output, so this same commit also proves those get collapsed to a literal "\n" in the file that reaches disk - otherwise a multi-line message could - /// desynchronize the line-based "git.<key>=<value>" format by spanning more than one physical line. - /// [Fact] public async Task Test() { - EmptyGitRepository emptyRepository = await Workspace.CreateEmptyRepositoryAsync("repo"); - - // \u-escaped rather than literal, so this source file itself stays plain ASCII: renders as accented Latin-1 - // supplement letters plus the trailing three characters of "commit", spelled out in Japanese (CJK). 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 File.WriteAllTextAsync(Path.Combine(emptyRepository.RootDirectory, ".gitignore"), "bin/\r\nobj/\r\n", TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(emptyRepository.RootDirectory, "file.txt"), "content", TestContext.Current.CancellationToken); + 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, "the short/subject line is never multi-line to begin with."); - - properties["git.commit.message.full"].Should().Be($@"{nonAsciiCommitSubject}\n\n{commitBody}", - "a real embedded newline must be escaped to a literal backslash-n, matching PackageReadme.md's own example output."); + 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/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs index f04f503ebf..5e1cf1dbf7 100644 --- a/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs @@ -8,15 +8,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest : GitPropertiesBuildTestBase { - /// - /// Every other test here consumes Steeltoe.Management.GitProperties.Build straight from source (ProjectReference + Import) - this is the only one that - /// goes through a real, packed .nupkg via <PackageReference>, the way an actual external user of the package would. That exercises the NuGet - /// "build\{PackageId}.targets" auto-import convention end-to-end (no explicit <Import> anywhere in the consumer project) and the in-process - /// (non-dev-loop) task-loading branch (SourceCheckout.txt is never packed, so it's absent in this layout - see $(GitPropertiesTaskHost) in - /// Steeltoe.Management.GitProperties.Build.targets). Isolated per andrewlock.net's "Creating a source generator, part 3" approach: a local folder feed - /// (just our own freshly-packed .nupkg, via a nuget.config with <clear/>) and a per-test RestorePackagesPath, so this never touches - or gets a - /// stale result from - the machine-wide global-packages cache at %userprofile%\.nuget\packages. - /// [Fact] public async Task Test() { @@ -24,29 +15,24 @@ public async Task Test() string feedDirectory = await Workspace.PackGitPropertiesBuildToFeedAsync(); string packageId = await TestPaths.GetPackageIdAsync(); string[] nuPkgFiles = Directory.GetFiles(feedDirectory, $"{packageId}.*.nupkg"); - nuPkgFiles.Should().ContainSingle("packing should produce exactly one .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("the .nupkg file name should embed the package version."); - string packageVersion = versionMatch.Groups[1].Value; + 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"); - string result = await consumer.BuildAsync($"-p:RestorePackagesPath={isolatedPackagesPath}"); - result.Should().Contain("0 Warning(s)", "a real package consumer should see no in-process task-loading fallback warning or any other diagnostic."); + DotNetCommandOutput output = await consumer.BuildAsync($"-p:RestorePackagesPath={isolatedPackagesPath}"); + output.Value.Should().Contain("0 Warning(s)"); - // NuGet always lowercases the package ID for the on-disk global-packages-folder layout - this isn't - // an arbitrary case normalization, so ToUpperInvariant() (as generally preferred) would look here for - // a folder that NuGet never creates. #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( - "the package should restore into the isolated path, never the machine-wide global-packages cache."); + Directory.Exists(Path.Combine(isolatedPackagesPath, lowerCasePackageId, packageVersion)).Should().BeTrue(); Dictionary properties = await consumer.ReadDebugPropertiesAsync(); string expectedCommitId = await repository.GetCommitIdAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs index 19a6010063..17a0332ecb 100644 --- a/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs +++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs @@ -7,9 +7,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// Runs external processes and captures stdout/stderr merged into a single string. -/// internal static class ProcessRunner { private static readonly string LocatorCommand = OperatingSystem.IsWindows() ? "where" : "which"; @@ -21,9 +18,8 @@ internal static class ProcessRunner ]; /// - /// Generous enough to comfortably cover the slowest single command this test suite ever runs (a Release build plus NuGet pack, or a "dotnet build" - /// against a cold/isolated restore) even under heavy system load, while still turning a genuine hang into an informative test failure instead of - /// blocking the whole suite indefinitely. + /// 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); @@ -96,10 +92,9 @@ private static async Task RunAsync(string fileName, string workingDirect startInfo.ArgumentList.Add(argument); } - // Without this, a spawned "dotnet build"/"publish" leaves a persistent MSBuild worker node running in the background for reuse by a later build - // (the SDK's default, off a dev machine with no CI environment variable set). That node inherits our redirected stdout/stderr pipe handles and - // keeps them open even after the process we launched here exits, so the read end never sees EOF, and awaiting exit below would otherwise block - // forever waiting for a pipe close that will never happen, even though the build already completed successfully. + // 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. startInfo.EnvironmentVariables["MSBUILDDISABLENODEREUSE"] = "1"; using var process = new Process(); @@ -144,9 +139,8 @@ void AppendLine(string? line) } #pragma warning disable S6507 // Blocks should not be synchronized on local variables - // Deliberately a call-scoped lock, not a shared static one: many tests run processes concurrently, and every process's stdout/stderr callback - // would otherwise contend for one single global lock. Under enough concurrent, high-volume output (e.g. "dotnet build -v:detailed"), that starves - // the thread pool and stalls the whole suite indefinitely. This lock only ever guards this one call's own StringBuilder. + // 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 { @@ -167,7 +161,7 @@ private static void KillEntireProcessTreeInBackground(int processId) } catch (Exception) { - // Intentionally left empty. + // Best-effort kill of an already-timed-out process. } }); } diff --git a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs index c85be5bb2f..17be5c8a5a 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs @@ -10,8 +10,7 @@ public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBas public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); - string result = await repository.TestApp.PublishAsync(); - result.Should().NotContain("duplicate"); + await repository.TestApp.PublishAsync(); Dictionary properties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); string expectedCommitId = await repository.GetCommitIdAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs index d7932ddad0..9c013f1c02 100644 --- a/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs +++ b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.cs @@ -11,8 +11,7 @@ public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); await repository.TestApp.BuildAsync("-c", "Release"); - string publishResult = await repository.TestApp.PublishAsync("-c", "Release", "--no-build"); - publishResult.Should().NotContain("duplicate"); + await repository.TestApp.PublishAsync("-c", "Release", "--no-build"); Dictionary properties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); string expectedCommitId = await repository.GetCommitIdAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs b/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs index 784fbf8758..4d3ec9d3ea 100644 --- a/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs +++ b/src/Management/test/GitProperties.Build.Test/RemotePushProjectTree.cs @@ -4,11 +4,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// The result of - a directory tree with no ".git" anywhere in its ancestry, the way a source-based `cf push` -/// actually delivers a project. Deliberately not a itself: nothing here is a git repository, so none of that type's -/// git-invoking members would make sense on it. -/// internal sealed class RemotePushProjectTree(string rootDirectory, TestProject testApp) { public string RootDirectory { get; } = rootDirectory; diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs deleted file mode 100644 index 5328bf1595..0000000000 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.cs +++ /dev/null @@ -1,22 +0,0 @@ -// 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; - -public sealed class ShallowCloneInfoWhenEnableWarningsFalseTest : GitPropertiesBuildTestBase -{ - /// - /// GITPROPS006 (unlike GITPROPS001-005) never blocks generation - the shallow clone is still fully usable, just with two fields left empty (see - /// ). Confirms $(GitPropertiesEnableWarnings) downgrades it to an informational message the same - /// way it does for the others. - /// - [Fact] - public async Task Test() - { - GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 1); - GitRepository shallow = await source.CloneAsShallowAsync("shallow"); - string result = await shallow.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); - result.AssertReportedAsInfoOnly("GITPROPS006", "repository is a shallow clone"); - } -} diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs index 02b3adaeca..aff7a094bc 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs @@ -15,10 +15,10 @@ public async Task Test() string isShallowRepository = await shallow.RunGitAsync("rev-parse", "--is-shallow-repository"); isShallowRepository.Should().Be("true"); - string result = await shallow.TestApp.BuildAsync(); - result.Should().NotContain("GITPROPS001"); - result.Should().NotContain("GITPROPS002"); - result.AssertWarned("GITPROPS006"); + 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(); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs new file mode 100644 index 0000000000..e010eda27e --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.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; + +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("-v:normal"); + defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryIsShallowClone); + + shallow.DeleteSharedCache(); + DotNetCommandOutput disableWarningsOutput = await shallow.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); + disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitRepositoryIsShallowClone, "repository is a shallow clone"); + + shallow.DeleteSharedCache(); + DotNetCommandOutput featureOffOutput = await shallow.TestApp.BuildAsync("-p:GenerateGitProperties=false"); + featureOffOutput.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryIsShallowClone); + + shallow.TestApp.GitPropertiesGenerated.Should().BeTrue(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs index d4cd1ceef8..8aadde6ce6 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs @@ -6,12 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest : GitPropertiesBuildTestBase { - /// - /// A consumer's explicit choice must never be second-guessed by the smart default, in either direction - the negative direction (no reference, but - /// explicitly forced on) is already exercised by every other test in this file, which all set $(GenerateGitProperties)=true explicitly via - /// WriteAppProject's default. This covers the other direction: a consuming-package reference IS present (the smart default would say "generate"), but - /// the consumer explicitly opted out anyway. - /// [Fact] public async Task Test() { diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs index e9f63c6e34..6731e0a9a5 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs @@ -6,12 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest : GitPropertiesBuildTestBase { - /// - /// The positive counterpart to : a project referencing the real default - /// consuming package ID (Steeltoe.Management.Endpoint) gets git.properties generated with no explicit $(GenerateGitProperties) needed. Uses a minimal - /// stand-in project with that exact name/PackageId (see WriteDummyDependencyProjectAsync's remarks) rather than the real, large Endpoint project, so - /// this test stays fast and fully offline. - /// [Fact] public async Task Test() { diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs index 7c702cf6ac..d887796de3 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs @@ -6,10 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class SmartDefaultOverrideDetectsCustomPackageIdsTest : GitPropertiesBuildTestBase { - /// - /// Proves $(GitPropertiesConsumingPackageIds) is genuinely overridable - for consumers of this package who don't use Steeltoe.Management.Endpoint at all - /// (e.g. a hand-rolled /info endpoint reading git.properties directly), so the smart default isn't hardcoded away from them. - /// [Fact] public async Task Test() { diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs index f90e2d358a..a28d93f8ab 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs @@ -6,11 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest : GitPropertiesBuildTestBase { - /// - /// Guards against a regression to a naive substring match (e.g. "IndexOf(id + "/")" without also requiring the match to be a whole library key) - a - /// project referencing only "Some2" (never "Some" itself) must NOT be detected when $(GitPropertiesConsumingPackageIds) is configured as "Some", even - /// though "Some2" starts with "Some". Proves DetectConsumingPackageReferenceTask compares whole package IDs, not prefixes. - /// [Fact] public async Task Test() { diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs index 69c7b9e7f7..5a417d82fa 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs @@ -6,13 +6,8 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest : GitPropertiesBuildTestBase { - /// - /// Guards against a regression where MSBuild's required-parameter check for a Task string parameter treats an empty string the same as "not supplied": - /// setting $(GitPropertiesConsumingPackageIds) to blank via a global property (e.g. "-p:GitPropertiesConsumingPackageIds=") reaches - /// DetectConsumingPackageReferenceTask.PackageIds unchanged (global properties can't be reassigned by the project's own conditional default at - /// ResolveGitPropertiesPaths above), so PackageIds must NOT be [Required] - it must instead behave exactly like "no configured ID happens to match", - /// i.e. skip generation gracefully rather than fail the build with MSB4044. - /// + // 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() { diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs index 88e97c4f5e..8952040dc1 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -6,21 +6,13 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest : GitPropertiesBuildTestBase { - /// - /// The default case for the overwhelming majority of projects in a large solution (a class library, a test project, anything without a consuming package - /// anywhere in its resolved dependency graph): generation is skipped entirely, without needing an explicit opt-out, and without breaking the build. A - /// real git repository is deliberately present here (unlike NoGitWarnsByDefaultTest) to prove the smart default - not "no .git found" - is what causes - /// the skip. - /// [Fact] public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1); TestProject testApp = await repository.AddProjectAsync(GitPropertiesTestWorkspace.TestAppProjectName, generateGitProperties: null); - - string result = await testApp.BuildAsync("-v:detailed"); - // Not a numbered GITPROPS0xx code - this is plain internal trace output, not a diagnosable outcome (see the .targets file's own comment on it). - result.Should().Contain("git.properties generation skipped: no reference to"); + DotNetCommandOutput output = await testApp.BuildAsync("-v:detailed"); + output.Value.Should().Contain("git.properties generation skipped: no reference to"); testApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/TestPaths.cs b/src/Management/test/GitProperties.Build.Test/TestPaths.cs index 01baf95bab..a44cb1e6f4 100644 --- a/src/Management/test/GitProperties.Build.Test/TestPaths.cs +++ b/src/Management/test/GitProperties.Build.Test/TestPaths.cs @@ -9,45 +9,13 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// Resolves paths to the CURRENT source of Steeltoe.Management.GitProperties.Build (not a stale git-tracked copy), so every test exercises whatever is -/// on disk right now. Resolved once per test run via the test source file's own location, so these tests work regardless of where the test assembly's -/// bin/ output lives. -/// -/// -/// is the only member here that needs a process (git) to resolve, so it's the only piece that's genuinely async - it's -/// started once, eagerly, and every dependent path below just awaits that same completed Task and does cheap, synchronous string/regex work on top, so -/// none of this re-invokes git. -/// internal static partial class TestPaths { - /// - /// The relative path (from a project sitting at the root of a test workspace) back to the copied Steeltoe.Management.GitProperties.Build source - see - /// . Mirrors the real repository's own "src/Management/src/GitProperties.Build" layout, since - /// Steeltoe.Management.GitProperties.Build.csproj's own "shared.props" import depends on being at that exact relative depth from the repository root. - /// public const string GitPropertiesBuildRelativePath = "src/Management/src/GitProperties.Build"; - /// - /// The TargetFramework every generated test-app .csproj (TestApp, ProjectA/ProjectB, the PackageReference consumer) is written with - read from this - /// test assembly's own $(TargetFramework) via the AssemblyMetadata item in Steeltoe.Management.GitProperties.Build.Test.csproj, so bumping that one - /// property is the only change needed when a new TFM becomes current. - /// public static readonly string TestAppTargetFramework = ResolveTestAppTargetFramework(); - - /// - /// A semicolon-separated "<TargetFrameworks>" value covering the current TFM () plus the one immediately - /// before it, in that order - e.g. "net10.0;net9.0". Computed rather than hardcoded, so this stays current automatically as the repository's own - /// baseline TFM moves forward. Used only by tests that specifically need a multi-targeted consumer project (MSBuild builds a multi-targeted project's - /// inner TFMs concurrently by default), rather than every generated test-app project. - /// public static readonly string MultiTargetTestFrameworks = BuildMultiTargetTestFrameworks(); - /// - /// Root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own "shared.props" import (and that file's own - /// imports) needs to resolve. Copied verbatim into every synthetic test repo, at the repository root, so a project built from this source outside the - /// real Steeltoe checkout still evaluates identically (analyzers, versioning, packaging properties, etc.). - /// public static readonly string[] SharedBuildInfrastructureFiles = [ "shared.props", @@ -92,19 +60,12 @@ public static async Task GetSourceCheckoutMarkerFileAsync() return Path.Combine(directory, "SourceCheckout.txt"); } - /// - /// The NuGet package/assembly ID, derived from the real .csproj's own file name rather than retyped, so a future rename only needs to happen once. - /// public static async Task GetPackageIdAsync() { string projectFile = await GetGitPropertiesBuildProjectFileAsync(); return Path.GetFileNameWithoutExtension(projectFile); } - /// - /// Steeltoe.Management.GitProperties.Build.csproj's own <TargetFramework> (netstandard2.0, as of this writing) - parsed from the real .csproj - /// rather than hardcoded, so a future retargeting can't silently desync this from the test that packs and consumes the compiled task assembly. - /// public static async Task GetGitPropertiesBuildTargetFrameworkAsync() { string projectFile = await GetGitPropertiesBuildProjectFileAsync(); @@ -146,11 +107,6 @@ private static string ResolveTestAppTargetFramework() return attribute?.Value ?? throw new InvalidOperationException("Could not resolve this test assembly's own TargetFramework from its AssemblyMetadata."); } - /// - /// Deliberately CancellationToken.None, not a specific test's TestContext.Current.CancellationToken: is a single, - /// process-wide resource shared by every test class running concurrently, resolved once by whichever test happens to touch this class first - see - /// ProcessRunner.ResolveGitExecutableAsync's own remarks for the identical reasoning. - /// private static async Task ResolveRepositoryRootAsync([CallerFilePath] string sourceFilePath = "") { string sourceDirectory = Path.GetDirectoryName(sourceFilePath) ?? throw new InvalidOperationException("Could not determine the test source directory."); diff --git a/src/Management/test/GitProperties.Build.Test/TestProject.cs b/src/Management/test/GitProperties.Build.Test/TestProject.cs index c5c249b488..54ea20fd14 100644 --- a/src/Management/test/GitProperties.Build.Test/TestProject.cs +++ b/src/Management/test/GitProperties.Build.Test/TestProject.cs @@ -4,11 +4,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// A single project directory under test (TestApp, ProjectA, a multi-targeted app, a dummy dependency, a pushed copy, a PackageReference consumer...). -/// Wraps the directory and project name so a test can build/publish it and read whatever git.properties it produced without re-deriving any of those -/// paths itself. Created by or - never directly. -/// internal sealed class TestProject(string rootDirectory, string name) { private readonly string _debugGitPropertiesFilePath = Path.Combine(rootDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); @@ -24,33 +19,33 @@ internal sealed class TestProject(string rootDirectory, string name) public bool FallbackGitPropertiesGenerated => File.Exists(FallbackFilePath); public bool CompiledAssemblyExists => File.Exists(Path.Combine(RootDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, $"{Name}.dll")); - /// - /// The relative XML this project's directory/name pair would need to be referenced as a <ProjectReference> from a sibling project - used by the - /// smart-default detection tests to wire a stand-in into the app under test. - /// public string ToProjectReferenceXml() { return $""""""; } - public Task BuildAsync(params string[] arguments) + public async Task BuildAsync(params string[] arguments) { - return RunDotnetAsync("build", arguments); + string output = await RunDotnetAsync("build", arguments); + return new DotNetCommandOutput(output); } - public Task PublishAsync(params string[] arguments) + public async Task PublishAsync(params string[] arguments) { - return RunDotnetAsync("publish", arguments); + string output = await RunDotnetAsync("publish", arguments); + return new DotNetCommandOutput(output); } - public Task PublishAsync(int exitCodeExpected, params string[] arguments) + public async Task PublishAsync(int exitCodeExpected, params string[] arguments) { - return RunDotnetAsync(exitCodeExpected, "publish", arguments); + string output = await RunDotnetAsync(exitCodeExpected, "publish", arguments); + return new DotNetCommandOutput(output); } - public Task RestoreAsync(params string[] arguments) + public async Task RestoreAsync(params string[] arguments) { - return RunDotnetAsync("restore", arguments); + string output = await RunDotnetAsync("restore", arguments); + return new DotNetCommandOutput(output); } private Task RunDotnetAsync(string command, params string[] arguments) @@ -84,10 +79,6 @@ public Task> ReadFallbackPropertiesAsync() return PropertiesFile.ReadAsync(FallbackFilePath); } - /// - /// Reads the per-target-framework "bin\Debug\<tfm>\git.properties" produced by a multi-targeted build of this project - see - /// . - /// public async Task>> ReadDebugPropertiesPerTargetFrameworkAsync(IEnumerable targetFrameworks) { List> result = []; diff --git a/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs index 30a8efbeec..d9117d6ea5 100644 --- a/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs +++ b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs @@ -4,20 +4,12 @@ namespace Steeltoe.Management.GitProperties.Build.Test; -/// -/// Writes the synthetic project files (.csproj/Program.cs/nuget.config) that dev-loop and PackageReference-consumer tests build against, and copies the -/// CURRENT Steeltoe.Management.GitProperties.Build source - plus the handful of root-level Steeltoe build infrastructure files its own "shared.props" -/// import chain needs - into a synthetic test repository, so every test exercises whatever is on disk right now, never a stale git-tracked copy. -/// internal static class TestProjectWriter { - /// - /// Copies the CURRENT Steeltoe.Management.GitProperties.Build source (csproj + all .cs files + the targets file + SourceCheckout.txt) into - /// "src\Management\src\GitProperties.Build" under the given repository root - the exact relative depth its own csproj's "shared.props" import expects. - /// Copying the marker file matters: without it, $(GitPropertiesTaskHost) in Steeltoe.Management.GitProperties.Build.targets would (correctly, but - /// unhelpfully for these tests) detect "packaged" consumption instead of the dev loop this is meant to simulate, silently skipping the TaskHostFactory - /// path every other test here relies on. - /// + private const string HelloWorldCode = """ + Console.WriteLine("Hello, World!"); + """; + private static async Task CopyGitPropertiesBuildSourceAsync(string repoRootDestination) { string destination = Path.Combine(repoRootDestination, TestPaths.GitPropertiesBuildRelativePath); @@ -38,11 +30,6 @@ private static async Task CopyGitPropertiesBuildSourceAsync(string repoRootDesti } } - /// - /// Copies the handful of root-level Steeltoe build infrastructure files that Steeltoe.Management.GitProperties.Build.csproj's own shared.props import - /// chain needs (versioning, packaging defaults, analyzers), so a synthetic test repo - which has none of Steeltoe's other real projects - still - /// evaluates the same properties a real build inside the repository would. - /// private static async Task CopySharedBuildInfrastructureAsync(string repoRootDestination) { Directory.CreateDirectory(repoRootDestination); @@ -54,35 +41,6 @@ private static async Task CopySharedBuildInfrastructureAsync(string repoRootDest } } - /// - /// Writes a minimal exe project named under the given repository root, referencing - /// Steeltoe.Management.GitProperties.Build the way a same-solution project (dev loop) would: a ReferenceOutputAssembly="false" ProjectReference (just to - /// order the build) plus an explicit Import of the .targets file - the NuGet "build\{PackageId}.targets" auto-import convention only kicks in for a real - /// PackageReference consumer (see NuGetPackage_ConsumedViaPackageReference_GeneratesGitProperties for that). Single-targeted at - /// unless is given, in which case the project multi-targets that - /// semicolon-separated list instead (see ). - /// - /// - /// The directory to write the project under - typically a repository root returned by - /// . - /// - /// - /// The project's name - also used as its directory and file name. - /// - /// - /// A semicolon-separated list of target frameworks to multi-target, or null for a single-targeted project at - /// . - /// - /// - /// Emits an explicit $(GenerateGitProperties) override when non-null - true for almost every test here, since they exist to test generation itself, not - /// the smart default that decides whether it runs at all. Pass null (letting the smart default apply) only for tests that specifically cover - /// DetectConsumingPackageReferenceTask/$(GitPropertiesConsumingPackageIds) - see . - /// - /// - /// Extra raw XML inserted into the same <ItemGroup> as the ProjectReference above - currently only used to add a second, normal (not - /// ReferenceOutputAssembly="false") ProjectReference to a stand-in, so it actually participates in - /// restore and shows up in this project's own project.assets.json (see DetectConsumingPackageReferenceTask's remarks for why that distinction matters). - /// public static async Task WriteAppProjectAsync(string repoRootDestination, string projectName, string? targetFrameworks = null, bool? generateGitProperties = true, string? extraItemGroupContent = null) { @@ -126,21 +84,11 @@ public static async Task WriteAppProjectAsync(string repoRootDestination """; await File.WriteAllTextAsync(Path.Combine(appDirectory, $"{projectName}.csproj"), projectContent, TestContext.Current.CancellationToken); - - await File.WriteAllTextAsync(Path.Combine(appDirectory, "Program.cs"), """ - Console.WriteLine("Hello, World!"); - """, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(appDirectory, "Program.cs"), HelloWorldCode, TestContext.Current.CancellationToken); return appDirectory; } - /// - /// Writes a minimal, do-nothing class library project - used only by the smart-default detection tests as a stand-in for a real dependency (e.g. - /// Steeltoe.Management.Endpoint itself, or some other actuator-registering package) that a test app references NORMALLY (see - /// 's caller), so it actually participates in restore and shows up in the referencing project's own - /// project.assets.json - unlike WriteAppProjectAsync's own ReferenceOutputAssembly="false" reference to Steeltoe.Management.GitProperties.Build, which - /// was verified (empirically, against a real "dotnet restore") to be excluded from the resolved dependency graph entirely. - /// public static async Task WriteDummyDependencyProjectAsync(string repoRootDestination, string projectName) { string projectDirectory = Path.Combine(repoRootDestination, projectName); @@ -157,11 +105,6 @@ await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.cspr return projectDirectory; } - /// - /// Copies the shared Steeltoe build infrastructure plus the CURRENT Steeltoe.Management.GitProperties.Build source into the given directory (which - /// becomes the "repository root" for relative-path purposes, whether or not it's actually a git repository), then writes a TestApp project referencing - /// it. Returns the TestApp directory. - /// public static async Task CopyCurrentProjectFilesAsync(string destination) { await CopySharedBuildInfrastructureAsync(destination); @@ -169,18 +112,6 @@ public static async Task CopyCurrentProjectFilesAsync(string destination return await WriteAppProjectAsync(destination, GitPropertiesTestWorkspace.TestAppProjectName); } - /// - /// Packs a FRESH COPY of the current Steeltoe.Management.GitProperties.Build source into a local folder feed, so the PackageReference-based consumption - /// test exercises the exact same source as every other test here - not a stale .nupkg left behind by an earlier `dotnet build` of the real repo. A plain - /// filesystem directory is a valid NuGet feed on its own; no server involved. Returns the feed directory: a plain `dotnet build` (not `dotnet pack`) - /// because GeneratePackageOnBuild already produces the .nupkg as a side effect of a Release build - `dotnet pack` alone does NOT build first here - /// (IncludeBuildOutput=false makes NuGet's Pack target skip its usual dependency on Build), so it fails with NU5019 ("file not found") against the DLL - /// our own <None Include="$(TargetPath)"> pack item expects to already exist. - /// - /// - /// The calling 's own - the pack source is written - /// under a "pack-source" subdirectory of it, kept separate from anything else the workspace writes. - /// public static async Task PackGitPropertiesBuildToFeedAsync(string workspaceRootDirectory) { string packSourceDirectory = Path.Combine(workspaceRootDirectory, "pack-source"); @@ -195,12 +126,7 @@ public static async Task PackGitPropertiesBuildToFeedAsync(string worksp return Path.Combine(projectDirectory, "bin", "tasks", targetFramework); } - /// - /// A minimal nuget.config that clears every inherited package source (machine-wide, user-wide, ambient) down to just the given local feed. Without < - /// clear/>, restore would still see nuget.org and friends - harmless here since nothing else is needed, but clearing makes the test fully - /// offline-capable and guarantees it's really our local build being consumed, not a same-named/versioned package resolved from somewhere else. - /// - public static async Task WriteIsolatedNuGetConfigAsync(string filePath, string feedDirectory) + public static async Task WriteNuGetConfigAsync(string filePath, string feedDirectory) { string content = $""" @@ -215,12 +141,6 @@ public static async Task WriteIsolatedNuGetConfigAsync(string filePath, string f await File.WriteAllTextAsync(filePath, content, TestContext.Current.CancellationToken); } - /// - /// A bare console app that consumes Steeltoe.Management.GitProperties.Build the way a real, external user would - via <PackageReference> against a - /// built .nupkg - as opposed to every other test here, which uses a ProjectReference/Import straight against source. No explicit <Import> of the - /// .targets file: that's the whole point of the "build\{PackageId}.targets" NuGet auto-import convention this package relies on, and this is the only - /// test that actually exercises it end-to-end. - /// public static async Task CreatePackageConsumerProjectAsync(string projectDirectory, string packageVersion) { Directory.CreateDirectory(projectDirectory); @@ -231,9 +151,6 @@ public static async Task CreatePackageConsumerProjectAsync(string projectDirecto Exe {TestPaths.TestAppTargetFramework} enable - true @@ -244,9 +161,6 @@ public static async Task CreatePackageConsumerProjectAsync(string projectDirecto """; await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Consumer.csproj"), projectContent, TestContext.Current.CancellationToken); - - await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), """ - Console.WriteLine("Hello, World!"); - """, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), HelloWorldCode, TestContext.Current.CancellationToken); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs index 3e99f22002..6c3be2a942 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs @@ -6,24 +6,16 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest : GitPropertiesBuildTestBase { - /// - /// The stable, documented entry point for step 1 of the "Recommended cf push workflow" (see PackageReadme.md) - confirms it actually produces a usable - /// fallback file, and that doing so never compiles anything (the whole reason to prefer it over a full "dotnet build" before a source push). - /// [Fact] public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); - repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback file should have been written next to the .csproj."); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); Dictionary properties = await repository.TestApp.ReadFallbackPropertiesAsync(); string expectedCommitId = await repository.GetCommitIdAsync(); properties["git.commit.id"].Should().Be(expectedCommitId); - - // "bin\Debug\\publish" gets created as empty, routine SDK scaffolding even here (PrepareForPublish's own setup) - checking for the absence - // of the compiled assembly itself, not just the bin directory, is what actually proves no compilation happened. - repository.TestApp.CompiledAssemblyExists.Should().BeFalse( - "this target must never compile the project - that's the whole point of using it instead of a full build before a source push."); + repository.TestApp.CompiledAssemblyExists.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs index 995fefeaa9..b3d6194172 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.cs @@ -6,25 +6,12 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest : GitPropertiesBuildTestBase { - /// - /// Documents/guards the one real caveat called out in PackageReadme.md: this target never produces real build output, so a local "dotnet publish - /// --no-build" afterward must fail - there is nothing compiled to publish. If this target's own implementation ever accidentally started producing - /// compiled output (defeating its "lightweight" purpose), this test would start failing for the opposite reason (publish --no-build would start - /// succeeding) - a signal to revisit the target, not just delete this test. - /// [Fact] public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile"); - - // Confirms the actual premise of this test: nothing was compiled, so there is genuinely nothing for - // "dotnet publish --no-build" below to publish. repository.TestApp.CompiledAssemblyExists.Should().BeFalse(); - - // 1, not just "nonzero": MSBuild's own long-standing, stable convention for "the build failed" (verified - // against a real "dotnet publish --no-build" in this exact no-compiled-output scenario) - checked here, - // at the point of the call, rather than via a separate assertion afterward. await repository.TestApp.PublishAsync(1, "--no-build"); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs index 838414082d..5eaf968642 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -6,14 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest : GitPropertiesBuildTestBase { - /// - /// End-to-end simulation of the actual documented workflow - the lightweight-target equivalent of - /// (which uses a full build instead): produce the fallback file via - /// - /// WriteGitPropertiesFallbackFile - /// - /// , simulate a source-based `cf push`, and confirm the server-side publish still picks it up correctly. - /// [Fact] public async Task Test() { @@ -22,12 +14,12 @@ public async Task Test() Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); RemotePushProjectTree remote = repository.SimulatePush("pushed"); - remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback git.properties must have survived the simulated push."); + remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); - string publishResult = await remote.TestApp.PublishAsync("-v:detailed"); - publishResult.Should().Contain("using pre-generated fallback file", "using the fallback should still be traceable, so it's never silently stale."); + DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:detailed"); + output.Value.Should().Contain("using pre-generated fallback file"); - Dictionary publishedProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); - publishedProperties.Should().BeEquivalentTo(fallbackProperties, "the fallback-produced output must exactly match the pre-generated fallback content."); + Dictionary publishProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); + publishProperties.Should().BeEquivalentTo(fallbackProperties); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs index 14090d786f..e1fd2d7078 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.cs @@ -6,10 +6,6 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class WriteGitPropertiesFallbackFileWorksWithNoRestoreTest : GitPropertiesBuildTestBase { - /// - /// "--no-restore" must work the same way for this target as for any other build invocation - it only requires that restore already happened at least - /// once, same as a normal build. - /// [Fact] public async Task Test() { diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs index 98155cdfe8..dc6ac48e60 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs @@ -10,26 +10,22 @@ public sealed class WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitP public async Task Test() { GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1, true); - string result1 = await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); - result1.Should().Contain( + output.Value.Should().Contain( $"git.properties: writing fallback copy to '{repository.TestApp.FallbackFilePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback file should have been written next to the .csproj."); + repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); Dictionary outputProperties1 = await repository.TestApp.ReadDebugPropertiesAsync(); - fallbackProperties.Should().BeEquivalentTo(outputProperties1, "the fallback file must carry the exact same content as the live build output."); + fallbackProperties.Should().BeEquivalentTo(outputProperties1); bool isDirty = await repository.IsDirtyAsync(); - isDirty.Should().BeFalse("the fallback file is gitignored, so it must not show up as an untracked change."); + isDirty.Should().BeFalse(); - // A gitignored fallback file left over from the first build must not itself make a LATER build see the tree as dirty. await repository.TestApp.BuildAsync("-p:GitPropertiesWriteToProjectDirectory=true"); - Dictionary outputProperties2 = await repository.TestApp.ReadDebugPropertiesAsync(); - - outputProperties2["git.dirty"].Should().Be("false", - "the gitignored fallback file from the first build must not make a later build see the tree as dirty."); + outputProperties2["git.dirty"].Should().Be("false"); } } diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs index 6573902762..308d2301f8 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.cs @@ -6,28 +6,24 @@ namespace Steeltoe.Management.GitProperties.Build.Test; public sealed class WriteToProjectDirectoryCreatesFallbackFileOnPublishTest : GitPropertiesBuildTestBase { - /// - /// "dotnet publish" runs its own compile/composition steps internally regardless of whether "dotnet build" ran first - this guards against the fallback - /// file only being written along the "build" target chain and silently never firing when publish is the very first command run against a fresh checkout - /// (a common real-world pattern: `dotnet publish` directly, without a separate build step). Runs with $(GitPropertiesEnableWarnings) at its default - /// (enabled) setting to confirm nothing about the fallback-writing path implicitly depends on warnings being suppressed - since a real .git repository - /// is available here, nothing should be skipped (and no GITPROPS0xx code should appear) regardless of that setting. - /// + // "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); - string result = await repository.TestApp.PublishAsync("-p:GitPropertiesWriteToProjectDirectory=true", "-p:GitPropertiesEnableWarnings=true"); - result.Should().NotContain("GITPROPS0", "nothing should be skipped, and no fallback should be needed, when a real .git repository is available."); - repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue( - "the fallback file should have been written next to the .csproj, even for a bare publish."); + 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 publishedProperties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); - fallbackProperties.Should().BeEquivalentTo(publishedProperties, "the fallback file must carry the exact same content as the published output."); + Dictionary publishProperties = await repository.TestApp.ReadReleasePublishPropertiesAsync(); + fallbackProperties.Should().BeEquivalentTo(publishProperties); bool isDirty = await repository.IsDirtyAsync(); - isDirty.Should().BeFalse("the fallback file is gitignored, so it must not show up as an untracked change."); + isDirty.Should().BeFalse(); } } From 482203f54b5e4142b096a5532c984350f9d4b1ab Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:14:01 +0200 Subject: [PATCH 16/20] Handle diag IDs, bugfixes --- .../ComposeGitPropertiesTask.cs | 78 ++++++++++++------- .../GenerateGitPropertiesCacheTask.cs | 42 ++++------ .../GitDiagnosticReporter.cs | 27 +++++++ .../src/GitProperties.Build/PackageReadme.md | 3 +- .../ReportGitDiagnosticTask.cs | 41 ++++++++++ ...toe.Management.GitProperties.Build.targets | 31 ++++---- .../DotNetCommandOutputAssertions.cs | 8 +- ...backFileIgnoredWhenLiveGitAvailableTest.cs | 2 +- ...allbackFileIsUsedWhenNoGitAvailableTest.cs | 2 +- .../GitDiagnosticIds.cs | 1 + .../GitDirtyStateUnknownWarnsByDefaultTest.cs | 37 +++++++++ ...GitExecutableNotFoundWarnsByDefaultTest.cs | 7 +- .../GitWorktreeWarnsByDefaultTest.cs | 7 +- .../GroundTruthAllPropertiesMatchGitTest.cs | 4 +- ...talBuildCacheSkipsButDirtyStaysLiveTest.cs | 2 +- .../MultiProjectSharesCacheTest.cs | 4 +- .../NoCommitsWarnsByDefaultTest.cs | 7 +- .../NoGitRepositoryWarnsByDefaultTest.cs | 4 +- .../ShallowCloneWarnsByDefaultTest.cs | 11 +-- ...tionWhenNoConsumingPackageReferenceTest.cs | 2 +- ...henSimulatedPushServerPublishUsesItTest.cs | 2 +- ...DirectoryCreatesFallbackFileOnBuildTest.cs | 5 +- 22 files changed, 222 insertions(+), 105 deletions(-) create mode 100644 src/Management/src/GitProperties.Build/GitDiagnosticReporter.cs create mode 100644 src/Management/src/GitProperties.Build/ReportGitDiagnosticTask.cs create mode 100644 src/Management/test/GitProperties.Build.Test/GitDirtyStateUnknownWarnsByDefaultTest.cs diff --git a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs index 413378bab1..f3dee4f4ae 100644 --- a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs +++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs @@ -9,10 +9,9 @@ namespace Steeltoe.Management.GitProperties.Build; /// -/// Merges the shared git.properties cache with the fields that can never be cached: the live working-tree 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. Every failure returns false, so a partial run can't leave -/// output mismatched with what's on disk for a later step to pick up. +/// 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 @@ -52,30 +51,16 @@ public sealed class ComposeGitPropertiesTask : Task /// 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() { - int exitCode; - string stdout; - string stderr; + bool? isDirty = DetermineDirtyState(); - try - { - exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, "status --porcelain", out stdout, out stderr); - } - catch (Exception exception) - { - Log.LogError($"git.properties: an unexpected error occurred while determining working tree status:{Environment.NewLine}{exception}"); - return false; - } - - if (exitCode != 0) - { - Log.LogError("git.properties: failed to determine working tree status: {0}", stderr); - return false; - } - - bool isDirty = stdout.Length > 0; List lines = []; if (!TryRunFileOperation($"read {CacheFile}", () => lines = AtomicFile.Read(CacheFile).ToList())) @@ -83,15 +68,22 @@ public override bool Execute() return false; } - for (int index = 0; index < lines.Count; index++) + if (isDirty == true) { - if (isDirty && lines[index].StartsWith($"{GitPropertiesFormat.CommitIdDescribeKey}=", StringComparison.Ordinal)) + for (int index = 0; index < lines.Count; index++) { - lines[index] += "-dirty"; + if (lines[index].StartsWith($"{GitPropertiesFormat.CommitIdDescribeKey}=", StringComparison.Ordinal)) + { + lines[index] += "-dirty"; + } } } - lines.Add($"git.dirty={(isDirty ? "true" : "false")}"); + 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 @@ -114,6 +106,36 @@ public override bool Execute() return TryRunFileOperation($"write fallback file {FallbackFile}", () => AtomicFile.Write(FallbackFile, lines)); } + 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; + } + private bool TryRunFileOperation(string description, Action action) { try diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index b017ab96da..cfee9127b0 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -18,7 +18,8 @@ namespace Steeltoe.Management.GitProperties.Build; // ReSharper disable once UnusedType.Global public sealed class GenerateGitPropertiesCacheTask : Task { - private const string DiagnosticPrefix = "GITPROPS"; + private const string VersionCheckArguments = "--version"; + private static readonly Version MinimumGitVersion = new(2, 15, 0); /// @@ -122,15 +123,15 @@ private GitVersionStatus CheckGitVersion() if (installedVersion == null) { - Log.LogError($"git.properties: could not parse the installed git version from '{GitExecutable} --version' output: '{output}'."); + Log.LogError($"git.properties: could not parse the installed git version from '{GitExecutable} {VersionCheckArguments}' output: '{output}'."); return GitVersionStatus.Unknown; } if (installedVersion < MinimumGitVersion) { - ReportDiagnostic(4, - $"git.properties generation skipped: installed git version {installedVersion} is older than the minimum supported version " + - $"({MinimumGitVersion}). Upgrade git to resolve this."); + 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."); return GitVersionStatus.Incompatible; } @@ -145,17 +146,19 @@ private GitVersionStatus CheckGitVersion() try { - exitCode = RunGit("--version", out output, out _); + exitCode = RunGit(VersionCheckArguments, out output, out _); } catch (Exception exception) { - ReportDiagnostic(3, $"git.properties generation skipped: could not run '{GitExecutable}' ({exception.Message})."); + string message = $"git.properties generation skipped: could not run '{GitExecutable}' ({exception.Message})."; + GitDiagnosticReporter.Report(Log, 3, EnableWarnings, message); return null; } if (exitCode != 0) { - ReportDiagnostic(3, $"git.properties generation skipped: '{GitExecutable} --version' exited with code {exitCode}."); + string message = $"git.properties generation skipped: '{GitExecutable} {VersionCheckArguments}' exited with code {exitCode}."; + GitDiagnosticReporter.Report(Log, 3, EnableWarnings, message); return null; } @@ -168,7 +171,8 @@ private GitVersionStatus CheckGitVersion() if (exitCode != 0 || stdout != "true") { - ReportDiagnostic(1, $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git repository."); + string message = $"git.properties generation skipped: '{RepositoryRoot}' is not inside a usable git repository."; + GitDiagnosticReporter.Report(Log, 1, EnableWarnings, message); return null; } @@ -176,7 +180,7 @@ private GitVersionStatus CheckGitVersion() if (exitCode != 0) { - ReportDiagnostic(5, "git.properties generation skipped: repository has no commits yet."); + GitDiagnosticReporter.Report(Log, 5, EnableWarnings, "git.properties generation skipped: repository has no commits yet."); return null; } @@ -212,7 +216,7 @@ private bool TryGenerate(string commitId) return true; } - Log.LogMessage("git.properties: generating shared cache at '{0}'.", CacheFile); + Log.LogMessage(MessageImportance.High, "git.properties: generating shared cache at '{0}'.", CacheFile); if (!TryRunGit("rev-parse --is-shallow-repository", "determine shallow-clone status", out string stdout)) { @@ -223,7 +227,7 @@ private bool TryGenerate(string commitId) if (isShallow) { - ReportDiagnostic(6, + 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)."); } @@ -406,20 +410,6 @@ private bool TryRunGit(string arguments, string description, out string stdout) return true; } - private void ReportDiagnostic(int diagnosticId, string message) - { - if (EnableWarnings) - { - string warningCode = $"{DiagnosticPrefix}{diagnosticId:D3}"; - Log.LogWarning(null, warningCode, null, null, 0, 0, 0, 0, message); - } - else - { - // Omit code, which is only useful to suppress warnings/errors. - Log.LogMessage(message); - } - } - /// /// Indicates whether the installed git version can be used. /// 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/PackageReadme.md b/src/Management/src/GitProperties.Build/PackageReadme.md index 5e7c90f84d..a2e7cd2cbb 100644 --- a/src/Management/src/GitProperties.Build/PackageReadme.md +++ b/src/Management/src/GitProperties.Build/PackageReadme.md @@ -1,6 +1,6 @@ # 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 working tree was "dirty" at build time, etc.) is automatically exposed at runtime. +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 @@ -64,6 +64,7 @@ This package may log one of the following codes: | `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 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/build/Steeltoe.Management.GitProperties.Build.targets b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets index 214e633285..d998984c4b 100644 --- a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -33,12 +33,15 @@ TaskFactory="TaskHostFactory" /> + + + Condition="'$(_GitPropertiesShouldGenerate)' == 'true' and '$(_GitPropertiesIncluded)' != 'true' and (Exists('$(GitPropertiesCacheFile)') or Exists('$(GitPropertiesFallbackFile)'))"> @@ -147,17 +144,17 @@ computes it later in the pipeline. --> + Condition="'$(_GitPropertiesShouldGenerate)' == 'true' and Exists('$(GitPropertiesCacheFile)') and '$(DesignTimeBuild)' != 'true' and '$(_GitPropertiesComposed)' != 'true'"> <_GitPropertiesFallbackFileTarget Condition="'$(GitPropertiesWriteToProjectDirectory)' == 'true'">$(GitPropertiesFallbackFile) + OutputFile="$(GitPropertiesFile)" Version="$(Version)" FallbackFile="$(_GitPropertiesFallbackFileTarget)" EnableWarnings="$(GitPropertiesEnableWarnings)" /> - + + Text="git.properties: writing fallback copy to '$([System.IO.Path]::GetFullPath('$(_GitPropertiesFallbackFileTarget)'))'." /> <_GitPropertiesComposed>true diff --git a/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs index 6a467f6ecc..1f685d79b1 100644 --- a/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs +++ b/src/Management/test/GitProperties.Build.Test/DotNetCommandOutputAssertions.cs @@ -10,6 +10,7 @@ internal sealed class DotNetCommandOutputAssertions(DotNetCommandOutput subject) : ReferenceTypeAssertions(subject) { private const string DiagnosticPrefix = "GITPROPS"; + protected override string Identifier => nameof(DotNetCommandOutput); [CustomAssertion] @@ -29,15 +30,14 @@ public void NotContainGitWarning(GitDiagnosticId diagnosticId) [CustomAssertion] public void NotContainAnyGitWarnings() { - Subject.Value.Should().NotContain(DiagnosticPrefix); + Subject.Value.Should().NotContain($"warning {DiagnosticPrefix}"); } [CustomAssertion] - public void ContainGitInfo(GitDiagnosticId diagnosticId, string messageSnippet) + public void ContainGitMessage(GitDiagnosticId diagnosticId) { string code = FormatCode(diagnosticId); - Subject.Value.Should().NotContain(code); - Subject.Value.Should().Contain(messageSnippet); + Subject.Value.Should().Contain($"message {code}"); } private static string FormatCode(GitDiagnosticId diagnosticId) diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs index bfb80a4933..b9f40a6337 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.cs @@ -11,7 +11,7 @@ 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:detailed"); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:normal"); output.Value.Should().NotContain("using pre-generated fallback file"); Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs b/src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs index 459ab1c3ec..b63049ab90 100644 --- a/src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs +++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIsUsedWhenNoGitAvailableTest.cs @@ -18,7 +18,7 @@ public async Task Test() remote.HasGitDirectory.Should().BeFalse(); remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); - DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:detailed"); + DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:normal"); output.Should().NotContainGitWarning(GitDiagnosticId.GitRepositoryNotFound); output.Value.Should().Contain("using pre-generated fallback file"); diff --git a/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs b/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs index dfeadf61dd..c2a326c773 100644 --- a/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs +++ b/src/Management/test/GitProperties.Build.Test/GitDiagnosticIds.cs @@ -12,6 +12,7 @@ internal sealed class GitDiagnosticId 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; } diff --git a/src/Management/test/GitProperties.Build.Test/GitDirtyStateUnknownWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitDirtyStateUnknownWarnsByDefaultTest.cs new file mode 100644 index 0000000000..6b89340740 --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/GitDirtyStateUnknownWarnsByDefaultTest.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. + +namespace Steeltoe.Management.GitProperties.Build.Test; + +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); + 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); + 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(); + } +} diff --git a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs index 81bd828b7c..bb6598f56b 100644 --- a/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs @@ -14,15 +14,16 @@ 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); + repository.TestApp.GitPropertiesGenerated.Should().BeFalse(); DotNetCommandOutput disableWarningsOutput = - await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false", "-v:normal"); + await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false"); - disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitExecutableNotFound, "git.properties generation skipped: could not run"); + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitExecutableNotFound); + 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(); } } diff --git a/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs index 2b944f7481..831e6dd6c9 100644 --- a/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GitWorktreeWarnsByDefaultTest.cs @@ -15,13 +15,14 @@ public async Task Test() DotNetCommandOutput defaultOutput = await testApp.BuildAsync(); defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitWorktreeFound); + testApp.GitPropertiesGenerated.Should().BeFalse(); - DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); - disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitWorktreeFound, "resolves to a git worktree or submodule"); + 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/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs index 7edf390a70..d3836502e8 100644 --- a/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs +++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs @@ -18,8 +18,8 @@ public async Task Test() repository.TestApp.FallbackGitPropertiesGenerated.Should().BeFalse(); - string expectedRelativePath = Path.Combine("obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); - output.Value.Should().Contain($"git.properties: writing to '{expectedRelativePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); + string expectedPath = Path.Combine(repository.TestApp.RootDirectory, "obj", "Debug", TestPaths.TestAppTargetFramework, "git.properties"); + output.Value.Should().Contain($"git.properties: writing to '{expectedPath}'."); Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs index 815e261d93..966c4dd33d 100644 --- a/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs +++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.cs @@ -13,7 +13,7 @@ public async Task Test() await repository.TestApp.BuildAsync(); repository.SharedCacheExists.Should().BeTrue(); - DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:detailed"); + DotNetCommandOutput output = await repository.TestApp.BuildAsync("-v:normal"); output.Value.Should().Contain("Skipping target \"GenerateGitPropertiesCache\""); Dictionary properties = await repository.TestApp.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs index 59006b449e..a73e5e04f0 100644 --- a/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs +++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.cs @@ -12,11 +12,11 @@ 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("-v:detailed"); + DotNetCommandOutput outputA = await projectA.BuildAsync(); outputA.Value.Should().Contain("git.properties: generating shared cache"); repository.SharedCacheExists.Should().BeTrue(); - DotNetCommandOutput outputB = await projectB.BuildAsync("-v:detailed"); + DotNetCommandOutput outputB = await projectB.BuildAsync(); outputB.Value.Should().NotContain("git.properties: generating shared cache"); Dictionary propertiesA = await projectA.ReadDebugPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs index df8f8413b1..c4070c38fa 100644 --- a/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs @@ -14,13 +14,14 @@ public async Task Test() 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", "-v:normal"); - disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitRepositoryHasNoCommits, "no commits yet"); + 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/NoGitRepositoryWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.cs index 74eb84689c..57968dfd7c 100644 --- a/src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/NoGitRepositoryWarnsByDefaultTest.cs @@ -15,8 +15,8 @@ public async Task Test() defaultOutput.Should().ContainGitWarning(GitDiagnosticId.GitRepositoryNotFound); testApp.GitPropertiesGenerated.Should().BeFalse(); - DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal"); - disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitRepositoryNotFound, "no usable .git directory found above"); + DotNetCommandOutput disableWarningsOutput = await testApp.BuildAsync("-p:GitPropertiesEnableWarnings=false"); + disableWarningsOutput.Should().ContainGitMessage(GitDiagnosticId.GitRepositoryNotFound); testApp.GitPropertiesGenerated.Should().BeFalse(); DotNetCommandOutput featureOffOutput = await testApp.BuildAsync("-p:GenerateGitProperties=false"); diff --git a/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs index e010eda27e..38b2617630 100644 --- a/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs +++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneWarnsByDefaultTest.cs @@ -12,17 +12,18 @@ public async Task Test() GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 1); GitRepository shallow = await source.CloneAsShallowAsync("shallow"); - DotNetCommandOutput defaultOutput = await shallow.TestApp.BuildAsync("-v:normal"); + 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", "-v:normal"); - disableWarningsOutput.Should().ContainGitInfo(GitDiagnosticId.GitRepositoryIsShallowClone, "repository is a shallow clone"); + 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().BeTrue(); + shallow.TestApp.GitPropertiesGenerated.Should().BeFalse(); } } diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs index 8952040dc1..a042ec7db1 100644 --- a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs +++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs @@ -11,7 +11,7 @@ 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:detailed"); + 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/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs index 5eaf968642..126efdcf75 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.cs @@ -16,7 +16,7 @@ public async Task Test() RemotePushProjectTree remote = repository.SimulatePush("pushed"); remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); - DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:detailed"); + DotNetCommandOutput output = await remote.TestApp.PublishAsync("-v:normal"); output.Value.Should().Contain("using pre-generated fallback file"); Dictionary publishProperties = await remote.TestApp.ReadReleasePublishPropertiesAsync(); diff --git a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs index dc6ac48e60..368dfa05fb 100644 --- a/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs +++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.cs @@ -11,10 +11,7 @@ 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}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'."); - + output.Value.Should().Contain($"git.properties: writing fallback copy to '{repository.TestApp.FallbackFilePath}'."); repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue(); Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync(); From afc34bfed3d7e23f15efe58691ad8f9b5401a03f Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:30:38 +0200 Subject: [PATCH 17/20] Add test for invalid git version --- .../GitPropertiesTestWorkspace.cs | 9 ++++ ...ncompatibleGitVersionWarnsByDefaultTest.cs | 29 +++++++++++ .../TestProjectWriter.cs | 51 ++++++++++++++----- 3 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 src/Management/test/GitProperties.Build.Test/IncompatibleGitVersionWarnsByDefaultTest.cs diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs index fe7838ed6b..a67b616e98 100644 --- a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs +++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs @@ -77,6 +77,15 @@ public async Task CreateProjectWithoutGitAsync(string name) return new TestProject(appDirectory, TestAppProjectName); } + public async Task CreateFakeGitExecutableAsync(string versionOutput) + { + string projectDirectory = await TestProjectWriter.WriteFakeGitExecutableProjectAsync(RootDirectory, "FakeGit", versionOutput); + await ProcessRunner.RunDotnetAsync(projectDirectory, "build"); + + string executableName = OperatingSystem.IsWindows() ? "FakeGit.exe" : "FakeGit"; + return Path.Combine(projectDirectory, "bin", "Debug", TestPaths.TestAppTargetFramework, executableName); + } + public async Task CreateEmptyRepositoryAsync(string name) { string directory = GetPath(name); diff --git a/src/Management/test/GitProperties.Build.Test/IncompatibleGitVersionWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/IncompatibleGitVersionWarnsByDefaultTest.cs new file mode 100644 index 0000000000..a71075872d --- /dev/null +++ b/src/Management/test/GitProperties.Build.Test/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; + +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/TestProjectWriter.cs b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs index d9117d6ea5..2c44524753 100644 --- a/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs +++ b/src/Management/test/GitProperties.Build.Test/TestProjectWriter.cs @@ -10,41 +10,41 @@ internal static class TestProjectWriter Console.WriteLine("Hello, World!"); """; - private static async Task CopyGitPropertiesBuildSourceAsync(string repoRootDestination) + private static async Task CopyGitPropertiesBuildSourceAsync(string destinationDirectory) { - string destination = Path.Combine(repoRootDestination, TestPaths.GitPropertiesBuildRelativePath); - Directory.CreateDirectory(Path.Combine(destination, "build")); + string basePath = Path.Combine(destinationDirectory, TestPaths.GitPropertiesBuildRelativePath); + Directory.CreateDirectory(Path.Combine(basePath, "build")); string projectFile = await TestPaths.GetGitPropertiesBuildProjectFileAsync(); string targetsFile = await TestPaths.GetTargetsFileAsync(); string markerFile = await TestPaths.GetSourceCheckoutMarkerFileAsync(); string buildDirectory = await TestPaths.GetGitPropertiesBuildDirectoryAsync(); - File.Copy(projectFile, Path.Combine(destination, Path.GetFileName(projectFile)), true); - File.Copy(targetsFile, Path.Combine(destination, "build", Path.GetFileName(targetsFile)), true); - File.Copy(markerFile, Path.Combine(destination, Path.GetFileName(markerFile)), true); + 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(destination, Path.GetFileName(sourceFile)), true); + File.Copy(sourceFile, Path.Combine(basePath, Path.GetFileName(sourceFile)), true); } } - private static async Task CopySharedBuildInfrastructureAsync(string repoRootDestination) + private static async Task CopySharedBuildInfrastructureAsync(string destinationDirectory) { - Directory.CreateDirectory(repoRootDestination); + Directory.CreateDirectory(destinationDirectory); string repositoryRoot = await TestPaths.GetRepositoryRootAsync(); foreach (string fileName in TestPaths.SharedBuildInfrastructureFiles) { - File.Copy(Path.Combine(repositoryRoot, fileName), Path.Combine(repoRootDestination, fileName), true); + File.Copy(Path.Combine(repositoryRoot, fileName), Path.Combine(destinationDirectory, fileName), true); } } - public static async Task WriteAppProjectAsync(string repoRootDestination, string projectName, string? targetFrameworks = null, + public static async Task WriteAppProjectAsync(string destinationDirectory, string projectName, string? targetFrameworks = null, bool? generateGitProperties = true, string? extraItemGroupContent = null) { - string appDirectory = Path.Combine(repoRootDestination, projectName); + string appDirectory = Path.Combine(destinationDirectory, projectName); Directory.CreateDirectory(appDirectory); string targetFrameworkElement = targetFrameworks == null @@ -89,9 +89,32 @@ public static async Task WriteAppProjectAsync(string repoRootDestination return appDirectory; } - public static async Task WriteDummyDependencyProjectAsync(string repoRootDestination, string projectName) + public static async Task WriteFakeGitExecutableProjectAsync(string destinationDirectory, string projectName, string versionOutput) { - string projectDirectory = Path.Combine(repoRootDestination, projectName); + string projectDirectory = Path.Combine(destinationDirectory, projectName); + Directory.CreateDirectory(projectDirectory); + + string projectContent = $""" + + + Exe + {TestPaths.TestAppTargetFramework} + enable + + + """; + + await File.WriteAllTextAsync(Path.Combine(projectDirectory, $"{projectName}.csproj"), projectContent, TestContext.Current.CancellationToken); + + string gitVersionCode = $"""Console.WriteLine("{versionOutput}");"""; + await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), gitVersionCode, TestContext.Current.CancellationToken); + + return projectDirectory; + } + + 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"), $""" From 4d5d52d2004357d27d2510cf0ed25de2db977bf0 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:34:31 +0200 Subject: [PATCH 18/20] Hide fallback file in IDE --- .../Steeltoe.Management.GitProperties.Build.targets | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index d998984c4b..14950293c4 100644 --- a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -10,6 +10,11 @@ 7 + + + + + @@ -120,8 +125,7 @@ - + @@ -130,8 +134,7 @@ Don't add it "for consistency", that would delete the one durable copy a git-less build depends on. --> - + From ecac54bb09cc8775f025fa75d98f5486b8bee101 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:39:31 +0200 Subject: [PATCH 19/20] add note about stale info --- src/Management/src/GitProperties.Build/PackageReadme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Management/src/GitProperties.Build/PackageReadme.md b/src/Management/src/GitProperties.Build/PackageReadme.md index a2e7cd2cbb..0601ae401e 100644 --- a/src/Management/src/GitProperties.Build/PackageReadme.md +++ b/src/Management/src/GitProperties.Build/PackageReadme.md @@ -104,3 +104,4 @@ This command writes an extra copy of `git.properties` directly next to your proj - **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`. From 9a9819317e9695f8e198cf76ed8d7e0f24bd107c Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:01:25 +0200 Subject: [PATCH 20/20] enable to hide write log messages in IDE --- .../GenerateGitPropertiesCacheTask.cs | 11 ++++++- .../src/GitProperties.Build/PackageReadme.md | 1 + ...toe.Management.GitProperties.Build.targets | 9 ++++-- ...ertiesReportFileWritesCanBeDisabledTest.cs | 31 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 src/Management/test/GitProperties.Build.Test/GitPropertiesReportFileWritesCanBeDisabledTest.cs diff --git a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs index cfee9127b0..1b40c8fbd1 100644 --- a/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs +++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs @@ -77,6 +77,12 @@ public sealed class GenerateGitPropertiesCacheTask : Task [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() { @@ -216,7 +222,10 @@ private bool TryGenerate(string commitId) return true; } - Log.LogMessage(MessageImportance.High, "git.properties: generating shared cache at '{0}'.", CacheFile); + if (ReportFileWrites) + { + Log.LogMessage(MessageImportance.High, "git.properties: generating shared cache at '{0}'.", CacheFile); + } if (!TryRunGit("rev-parse --is-shallow-repository", "determine shallow-clone status", out string stdout)) { diff --git a/src/Management/src/GitProperties.Build/PackageReadme.md b/src/Management/src/GitProperties.Build/PackageReadme.md index 0601ae401e..42dbc08c20 100644 --- a/src/Management/src/GitProperties.Build/PackageReadme.md +++ b/src/Management/src/GitProperties.Build/PackageReadme.md @@ -48,6 +48,7 @@ All settings are optional MSBuild properties, set in your project file (or a `Di | `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. | 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 index 14950293c4..bb1ddf8f73 100644 --- a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets +++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets @@ -5,6 +5,7 @@ false $(MSBuildProjectDirectory)\git.properties true + true Steeltoe.Management.Endpoint git 7 @@ -113,7 +114,8 @@ Condition="'$(_GitPropertiesShouldGenerate)' == 'true' and '$(TargetFramework)' != '' and '$(DesignTimeBuild)' != 'true'" Inputs="@(_GitPropertiesCacheInputs)" Outputs="$(GitPropertiesCacheFile)"> + CacheFile="$(GitPropertiesCacheFile)" CommitIdAbbrevLength="$(GitCommitIdAbbrevLength)" EnableWarnings="$(GitPropertiesEnableWarnings)" + ReportFileWrites="$(GitPropertiesReportFileWrites)" />