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/AtomicFile.cs b/src/Management/src/GitProperties.Build/AtomicFile.cs
new file mode 100644
index 0000000000..e10fa00710
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/AtomicFile.cs
@@ -0,0 +1,147 @@
+// 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 (IsTransientError(exception))
+ {
+ lastError = exception;
+ Thread.Sleep(ReadWriteRetryDelay);
+ }
+ }
+
+ throw new IOException($"Failed to write {path} after {MaxAttempts} attempts.", 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 (IsTransientError(exception))
+ {
+ lastError = exception;
+ Thread.Sleep(ReadWriteRetryDelay);
+ }
+ }
+
+ 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
+ {
+ File.Move(sourcePath, destinationPath);
+ }
+ catch (IOException) when (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 (IsTransientError(exception))
+ {
+ if (CurrentTimeUtc >= deadlineUtc)
+ {
+ return null;
+ }
+
+ Thread.Sleep(AcquireLockRetryDelay);
+ }
+ }
+ }
+
+ private static bool IsTransientError(Exception exception)
+ {
+ return exception is IOException or UnauthorizedAccessException;
+ }
+}
diff --git a/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs
new file mode 100644
index 0000000000..f812015b90
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.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.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. 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
+{
+ ///
+ /// 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;
+ 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)
+ {
+ 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.ReadAllLinesWithRetry(CacheFile).ToList()))
+ {
+ return false;
+ }
+
+ for (int index = 0; index < lines.Count; index++)
+ {
+ if (isDirty && lines[index].StartsWith($"{GitPropertiesFormat.CommitIdDescribeKey}=", StringComparison.Ordinal))
+ {
+ lines[index] += "-dirty";
+ }
+ }
+
+ 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.
+#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)))
+ {
+ return false;
+ }
+
+ if (FallbackFile is null or "")
+ {
+ 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
+ {
+ action();
+ return true;
+ }
+ catch (Exception exception)
+ {
+ Log.LogError($"git.properties: failed to {description}:{Environment.NewLine}{exception}");
+ return false;
+ }
+ }
+}
diff --git a/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs
new file mode 100644
index 0000000000..d9d6ccf63a
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/DetectConsumingPackageReferenceTask.cs
@@ -0,0 +1,98 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+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 (Exception exception)
+ {
+ Log.LogError($"git.properties: failed to read '{ProjectAssetsFile}' while checking for a consuming package reference:" +
+ $"{Environment.NewLine}{exception}");
+
+ return 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..24116d0408
--- /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 (Exception exception)
+ {
+ Log.LogError($"git.properties: failed to walk up from '{StartDirectory}' looking for a git repository root:{Environment.NewLine}{exception}");
+ return 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..7c32fe5029
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/GenerateGitPropertiesCacheTask.cs
@@ -0,0 +1,570 @@
+// 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;
+
+///
+/// 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);
+
+ ///
+ /// 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);
+
+ ///
+ /// 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;
+ }
+
+ try
+ {
+ string? commitId = Preflight();
+
+ if (commitId == null)
+ {
+ return true;
+ }
+
+ return TryGenerateAndWriteCache(commitId);
+ }
+ catch (Exception exception)
+ {
+ Log.LogError($"git.properties: an unexpected error occurred while generating the shared cache:{Environment.NewLine}{exception}");
+ return false;
+ }
+ }
+
+ ///
+ /// 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();
+
+ if (output == null)
+ {
+ return GitVersionStatus.Incompatible;
+ }
+
+ Version? installedVersion = GitOutputParser.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 (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;
+ 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;
+ }
+
+ ///
+ /// 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 ).
+ /// 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)
+ {
+ 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 = 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,
+ CacheLockTimeout.TotalSeconds);
+ }
+ else if (WasCacheRewrittenWhileWaitingForLock(cacheWriteTimeBeforeLock))
+ {
+ Log.LogMessage(
+ "git.properties: shared cache at '{0}' was rewritten by another concurrently-building project or target framework while waiting for " +
+ "the lock - skipping.", CacheFile);
+
+ return true;
+ }
+
+ Log.LogMessage("git.properties: generating shared cache at '{0}'.", CacheFile);
+
+ if (!TryRunGit("rev-parse --is-shallow-repository", "determine shallow-clone status", out string stdout))
+ {
+ 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={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
+ {
+ AtomicFile.WriteAtomic(CacheFile, lines);
+ }
+ catch (Exception exception)
+ {
+ Log.LogError($"git.properties: failed to write {CacheFile}:{Environment.NewLine}{exception}");
+ 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()
+ {
+ 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))
+ {
+ 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);
+ }
+
+ ///
+ /// 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 _);
+ 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.
+ description = new TagDescription(description.BaseDescribe, description.ClosestTagName, string.Empty);
+ }
+
+ return description;
+ }
+
+ private TagsAndCommitCount? ReadTagsAndTotalCommitCount(bool isShallow)
+ {
+ if (!TryRunGit("tag --points-at HEAD", "list tags", out string stdout))
+ {
+ return null;
+ }
+
+ string tags = string.Join(",", stdout.Split(LineSeparators, StringSplitOptions.RemoveEmptyEntries));
+
+ string totalCommitCount = string.Empty;
+
+ if (!isShallow)
+ {
+ if (!TryRunGit("rev-list --count HEAD", "count commits", out stdout))
+ {
+ return null;
+ }
+
+ totalCommitCount = stdout;
+ }
+
+ return new TagsAndCommitCount(tags, totalCommitCount);
+ }
+
+ private GitConfig? ReadConfig()
+ {
+ if (!TryRunGit("config --list", "read git config", out string stdout))
+ {
+ return null;
+ }
+
+ 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;
+ 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);
+ }
+
+ ///
+ /// 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
+ /// 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);
+ }
+ }
+
+ ///
+ /// 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 - 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
+ }
+
+ ///
+ /// 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 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;
+ }
+}
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/GitProcessRunner.cs b/src/Management/src/GitProperties.Build/GitProcessRunner.cs
new file mode 100644
index 0000000000..e7912d8484
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/GitProcessRunner.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using System.Text;
+
+namespace Steeltoe.Management.GitProperties.Build;
+
+internal static class GitProcessRunner
+{
+ public static int Run(string gitExecutable, string repositoryRoot, string arguments, out string stdout, out string stderr)
+ {
+ var stdoutBuilder = new StringBuilder();
+ var stderrBuilder = new StringBuilder();
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = gitExecutable,
+ Arguments = arguments,
+ WorkingDirectory = repositoryRoot,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8
+ };
+
+ using var process = new Process();
+ process.StartInfo = startInfo;
+ process.OutputDataReceived += (_, eventArgs) => AppendLine(stdoutBuilder, eventArgs.Data);
+ process.ErrorDataReceived += (_, eventArgs) => AppendLine(stderrBuilder, eventArgs.Data);
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ process.WaitForExit();
+
+ stdout = stdoutBuilder.ToString().Trim();
+ stderr = stderrBuilder.ToString().Trim();
+ return process.ExitCode;
+ }
+
+ private static void AppendLine(StringBuilder builder, string? line)
+ {
+ if (line == null)
+ {
+ return;
+ }
+
+ // git itself always writes \n line endings on its own output (even on Windows).
+ builder.Append(line).Append('\n');
+ }
+}
diff --git a/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs b/src/Management/src/GitProperties.Build/GitPropertiesFormat.cs
new file mode 100644
index 0000000000..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");
+ }
+}
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/TagDescription.cs b/src/Management/src/GitProperties.Build/TagDescription.cs
new file mode 100644
index 0000000000..39baf83e93
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/TagDescription.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build;
+
+internal sealed class TagDescription(string baseDescribe, string closestTagName, string closestTagCommitCount)
+{
+ public static TagDescription Empty { get; } = new(string.Empty, string.Empty, string.Empty);
+
+ public string BaseDescribe { get; } = baseDescribe;
+ public string ClosestTagName { get; } = closestTagName;
+ public string ClosestTagCommitCount { get; } = closestTagCommitCount;
+}
diff --git a/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets
new file mode 100644
index 0000000000..aff89255b8
--- /dev/null
+++ b/src/Management/src/GitProperties.Build/build/Steeltoe.Management.GitProperties.Build.targets
@@ -0,0 +1,384 @@
+
+
+
+
+ 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\config" Condition="Exists('$(GitRepositoryRoot).git\config')" />
+ <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\packed-refs" Condition="Exists('$(GitRepositoryRoot).git\packed-refs')" />
+ <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\refs\heads\**\*" />
+ <_GitPropertiesCacheInputs Include="$(GitRepositoryRoot).git\refs\tags\**\*" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_GitPropertiesIncluded>true
+
+
+
+
+
+
+
+ <_GitPropertiesFallbackFileTarget Condition="'$(GitPropertiesWriteToProjectDirectory)' == 'true'">$(GitPropertiesFallbackFile)
+
+
+
+
+
+
+
+
+
+ <_GitPropertiesComposed>true
+
+
+
+
+
+
+
+
+
diff --git a/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs b/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs
index c6012013d1..31951c6930 100644
--- a/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs
+++ b/src/Management/test/Endpoint.Test/Actuators/Info/Contributors/GitInfoContributorTest.cs
@@ -5,6 +5,7 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Steeltoe.Common.TestResources;
+using Steeltoe.Common.TestResources.IO;
using Steeltoe.Management.Endpoint.Actuators.Info;
using Steeltoe.Management.Endpoint.Actuators.Info.Contributors;
@@ -12,6 +13,44 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.Info.Contributors;
public sealed class GitInfoContributorTest
{
+ [Fact]
+ public void Default_path_prefers_base_directory_over_current_directory()
+ {
+ using var baseDirectory = new Sandbox();
+ using var currentDirectory = new Sandbox();
+
+ string baseDirectoryFile = baseDirectory.CreateFile("git.properties", "git.commit.id=from-base-directory");
+ currentDirectory.CreateFile("git.properties", "git.commit.id=from-current-directory");
+
+ string resolvedPath = GitInfoContributor.ResolveDefaultPropertiesPath(baseDirectory.FullPath, currentDirectory.FullPath);
+
+ resolvedPath.Should().Be(baseDirectoryFile);
+ }
+
+ [Fact]
+ public void Default_path_falls_back_to_current_directory_when_not_found_in_base_directory()
+ {
+ using var baseDirectory = new Sandbox();
+ using var currentDirectory = new Sandbox();
+
+ string currentDirectoryFile = currentDirectory.CreateFile("git.properties", "git.commit.id=from-current-directory");
+
+ string resolvedPath = GitInfoContributor.ResolveDefaultPropertiesPath(baseDirectory.FullPath, currentDirectory.FullPath);
+
+ resolvedPath.Should().Be(currentDirectoryFile);
+ }
+
+ [Fact]
+ public void Default_path_falls_back_to_current_directory_when_not_found_anywhere()
+ {
+ using var baseDirectory = new Sandbox();
+ using var currentDirectory = new Sandbox();
+
+ string resolvedPath = GitInfoContributor.ResolveDefaultPropertiesPath(baseDirectory.FullPath, currentDirectory.FullPath);
+
+ resolvedPath.Should().Be(Path.Combine(currentDirectory.FullPath, "git.properties"));
+ }
+
[Fact]
public async Task Logs_warning_when_git_properties_file_not_found()
{
@@ -51,6 +90,39 @@ public async Task Can_read_empty_git_properties_file()
loggerProvider.GetAsText().Should().BeEmpty();
}
+ [Fact]
+ public async Task Multi_line_commit_message_keeps_the_escaped_literal_backslash_n()
+ {
+ using var directory = new Sandbox();
+
+ string path = directory.CreateFile("git.properties", """
+ git.commit.message.short=Fix null reference in health check
+ git.commit.message.full=Fix null reference in health check\n\nAdds a null check before calling Ping().
+ """);
+
+ using var loggerFactory = new LoggerFactory();
+ var contributor = new GitInfoContributor(path, loggerFactory.CreateLogger());
+ var infoBuilder = new InfoBuilder();
+
+ await contributor.ContributeAsync(infoBuilder, TestContext.Current.CancellationToken);
+
+ IDictionary data = infoBuilder.Build();
+ string json = JsonSerializer.Serialize(data);
+
+ json.Should().BeJson("""
+ {
+ "git": {
+ "commit": {
+ "message": {
+ "full": "Fix null reference in health check\\n\\nAdds a null check before calling Ping().",
+ "short": "Fix null reference in health check"
+ }
+ }
+ }
+ }
+ """);
+ }
+
[Fact]
public async Task Skips_malformed_lines_in_git_properties_file()
{
diff --git a/src/Management/test/GitProperties.Build.Test/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
new file mode 100644
index 0000000000..42e307bade
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/BuildTimeChangesAcrossBuildsUnlikeCommitTimeTest.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 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()
+ {
+ 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
+ // 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.");
+ }
+}
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..6a9cbc6a46
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/EmptyGitRepository.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;
+
+///
+/// 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);
+ }
+
+ 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()
+ {
+ 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
new file mode 100644
index 0000000000..4ba73ec970
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/FallbackFileIgnoredWhenLiveGitAvailableTest.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 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.");
+
+ 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
new file mode 100644
index 0000000000..13bd0d467e
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/FallbackFileUsedWhenNoGitAvailableTest.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+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
new file mode 100644
index 0000000000..36a126159b
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/FallbackFileWithoutGitignoreMakesLaterBuildsAppearDirtyTest.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 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.");
+
+ 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.");
+ }
+}
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..ec2c8a3724
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GitExecutableNotFoundWarnsByDefaultTest.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+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 Test()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ string defaultResult = await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}");
+ defaultResult.AssertWarned("GITPROPS003");
+
+ string enableWarningsFalseResult =
+ await repository.TestApp.BuildAsync($"-p:GitExecutable={BogusGitExecutable}", "-p:GitPropertiesEnableWarnings=false", "-v:normal");
+
+ 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
new file mode 100644
index 0000000000..8300fdbd76
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GitFileWarnsByDefaultTest.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 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
new file mode 100644
index 0000000000..610dc440ac
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesBuildTestBase.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;
+
+///
+/// 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 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!;
+
+ public async ValueTask InitializeAsync()
+ {
+ Workspace = await GitPropertiesTestWorkspace.CreateAsync();
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ Workspace.Dispose();
+ GC.SuppressFinalize(this);
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs
new file mode 100644
index 0000000000..7fb6fca340
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GitPropertiesTestWorkspace.cs
@@ -0,0 +1,174 @@
+// 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 - 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; }
+
+ private GitPropertiesTestWorkspace(string rootDirectory)
+ {
+ RootDirectory = rootDirectory;
+ }
+
+ public static async Task CreateAsync()
+ {
+ string rootDirectory = Path.Combine(Path.GetTempPath(), $"build-tasks-test_{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8]}");
+ Directory.CreateDirectory(rootDirectory);
+ string physicalRootDirectory = await ResolvePhysicalPathAsync(rootDirectory);
+ return new GitPropertiesTestWorkspace(physicalRootDirectory);
+ }
+
+ ///
+ /// 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())
+ {
+ return path;
+ }
+
+ string output = await ProcessRunner.RunPwdAsync(path);
+ return output.Trim();
+ }
+
+ 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);
+ }
+ }
+
+ ///
+ /// 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);
+ await GitRepositoryBuilder.InitializeEmptyAsync(directory);
+ 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)
+ {
+ 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 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..876b5eabfb
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GitRepository.cs
@@ -0,0 +1,115 @@
+// 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);
+ }
+
+ ///
+ /// 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);
+ 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/GitRepositoryBuilder.cs b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs
new file mode 100644
index 0000000000..5c09027972
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GitRepositoryBuilder.cs
@@ -0,0 +1,131 @@
+// 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 GitRepositoryBuilder
+{
+ private static readonly HashSet SimulatedPushExcludedDirectoryNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".git",
+ "bin",
+ "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)
+ {
+ 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
+ ? """
+ bin/
+ obj/
+ git.properties
+ """
+ : """
+ bin/
+ obj/
+ """;
+
+ await File.WriteAllTextAsync(Path.Combine(destination, ".gitignore"), gitignoreContent, TestContext.Current.CancellationToken);
+
+ for (int commitNumber = 1; commitNumber <= commitCount; commitNumber++)
+ {
+ await File.WriteAllTextAsync(Path.Combine(destination, $"file{commitNumber}.txt"), $"content {commitNumber}",
+ TestContext.Current.CancellationToken);
+
+ await CommitAllAsync(destination, $"Commit {commitNumber}");
+ }
+ }
+
+ ///
+ /// 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");
+
+ if (body == null)
+ {
+ await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", subject);
+ }
+ else
+ {
+ await ProcessRunner.RunGitAsync(repositoryDirectory, "commit", "--quiet", "-m", subject, "-m", body);
+ }
+ }
+
+ ///
+ /// 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/GroundTruthAllPropertiesMatchGitTest.cs b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs
new file mode 100644
index 0000000000..cf9302f8e2
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/GroundTruthAllPropertiesMatchGitTest.cs
@@ -0,0 +1,112 @@
+// 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 Test()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 3);
+ string result = await repository.TestApp.BuildAsync();
+
+ 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 repository.TestApp.ReadDebugPropertiesAsync();
+
+ string expectedCommitId = await repository.GetCommitIdAsync();
+ properties["git.commit.id"].Should().Be(expectedCommitId);
+
+ string expectedCommitIdAbbrev = await repository.RunGitAsync("rev-parse", "--short=7", "HEAD");
+ properties["git.commit.id.abbrev"].Should().Be(expectedCommitIdAbbrev);
+
+ string expectedCommitUserName = await repository.RunGitAsync("log", "-1", "--format=%an");
+ properties["git.commit.user.name"].Should().Be(expectedCommitUserName);
+
+ string expectedCommitUserEmail = await repository.RunGitAsync("log", "-1", "--format=%ae");
+ properties["git.commit.user.email"].Should().Be(expectedCommitUserEmail);
+
+ string expectedCommitMessageShort = await repository.RunGitAsync("log", "-1", "--format=%s");
+ properties["git.commit.message.short"].Should().Be(expectedCommitMessageShort);
+
+ string expectedTotalCommitCount = await repository.RunGitAsync("rev-list", "--count", "HEAD");
+ properties["git.total.commit.count"].Should().Be(expectedTotalCommitCount);
+
+ bool expectedDirty = await repository.IsDirtyAsync();
+ properties["git.dirty"].Should().Be(expectedDirty ? "true" : "false");
+
+ // SDK default when $(Version) isn't set.
+ properties["git.build.version"].Should().Be("1.0.0");
+
+ DateTimeOffset.TryParse(properties["git.build.time"], CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset buildTime).Should().BeTrue(
+ "git.build.time must be a parseable, ISO-8601-with-offset timestamp, matching the style git itself uses for git.commit.time.");
+
+ buildTime.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromMinutes(5), "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..03b018a498
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/IncrementalBuildCacheSkipsButDirtyStaysLiveTest.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 IncrementalBuildCacheSkipsButDirtyStaysLiveTest : GitPropertiesBuildTestBase
+{
+ [Fact]
+ 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.");
+
+ 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 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
new file mode 100644
index 0000000000..823f6ee334
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/MultiProjectSharesCacheTest.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 MultiProjectSharesCacheTest : GitPropertiesBuildTestBase
+{
+ [Fact]
+ 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");
+
+ 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.");
+
+ 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
new file mode 100644
index 0000000000..5b315586fe
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/MultiTargetedProjectSharesCacheAcrossTargetFrameworksTest.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 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()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ TestProject testApp = await repository.AddProjectAsync("MultiTargetApp", TestPaths.MultiTargetTestFrameworks);
+ string[] frameworks = TestPaths.MultiTargetTestFrameworks.Split(';');
+ await testApp.BuildAsync();
+
+ string expectedCommitId = await repository.GetCommitIdAsync();
+ List> propertiesBefore = await testApp.ReadDebugPropertiesPerTargetFrameworkAsync(frameworks);
+
+ foreach (Dictionary properties in propertiesBefore)
+ {
+ properties["git.commit.id"].Should().Be(expectedCommitId);
+ properties["git.tags"].Should().BeEmpty();
+ }
+
+ await repository.TagAsync("v1.0.0");
+ await testApp.BuildAsync();
+ List> propertiesAfter = await testApp.ReadDebugPropertiesPerTargetFrameworkAsync(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..795774a2e2
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/MultipleRemotesOnlyOriginUrlIsUsedTest.cs
@@ -0,0 +1,50 @@
+// 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. 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()
+ {
+ 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 " +
+ "'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.");
+ }
+}
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..cdc2bc5736
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/NewTagInvalidatesCacheTest.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 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()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ await repository.TestApp.BuildAsync();
+ Dictionary propertiesBefore = await repository.TestApp.ReadDebugPropertiesAsync();
+ propertiesBefore["git.tags"].Should().BeEmpty();
+
+ string ancestorCommitId = await repository.RunGitAsync("rev-parse", "HEAD~1");
+ await repository.TagAsync("release-1.0", ancestorCommitId);
+
+ await repository.TestApp.BuildAsync();
+ Dictionary propertiesAfter = await repository.TestApp.ReadDebugPropertiesAsync();
+
+ propertiesAfter["git.tags"].Should().BeEmpty("the tag points at an ancestor, not HEAD, so it must not show up in git.tags.");
+ propertiesAfter["git.closest.tag.name"].Should().Be("release-1.0");
+ propertiesAfter["git.closest.tag.commit.count"].Should().Be("1", "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/NoCommitsWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.cs
new file mode 100644
index 0000000000..e38cb021f9
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/NoCommitsWarnsByDefaultTest.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 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");
+
+ string enableWarningsFalseResult = await repository.TestApp.BuildAsync("-p:GitPropertiesEnableWarnings=false", "-v:normal");
+ enableWarningsFalseResult.AssertReportedAsInfoOnly("GITPROPS005", "no commits yet");
+
+ 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
new file mode 100644
index 0000000000..df04f12f85
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/NoGitInfoWhenEnableWarningsFalseTest.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+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/NoGitWarnsByDefaultTest.cs b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs
new file mode 100644
index 0000000000..e51619a2a4
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/NoGitWarnsByDefaultTest.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+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
new file mode 100644
index 0000000000..e2b1506505
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/NonAsciiCommitDataRendersCorrectlyTest.cs
@@ -0,0 +1,42 @@
+// 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
+{
+ ///
+ /// 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().";
+
+ 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.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.");
+ }
+}
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..f04f503ebf
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/NuGetPackageConsumedViaPackageReferenceGeneratesGitPropertiesTest.cs
@@ -0,0 +1,55 @@
+// 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 Test()
+ {
+ 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.");
+
+ 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;
+
+ 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.");
+
+ // 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 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
new file mode 100644
index 0000000000..0f87671b83
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/ProcessRunner.cs
@@ -0,0 +1,250 @@
+// 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'
+ ];
+
+ ///
+ /// 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();
+
+ ///
+ /// 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();
+
+ 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);
+ }
+
+ // 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);
+ process.ErrorDataReceived += (_, eventArgs) => AppendLine(eventArgs.Data);
+ process.Start();
+ 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)
+ {
+ 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();
+
+ process.ExitCode.Should().Be(exitCodeExpected, "'{0} {1}' in '{2}' was expected to exit with code {3}. Output:\n{4}", fileName,
+ string.Join(' ', arguments), workingDirectory, exitCodeExpected, output);
+
+ return output;
+
+ void AppendLine(string? line)
+ {
+ if (line == null)
+ {
+ return;
+ }
+
+ // 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);
+ }
+ }
+ }
+
+ ///
+ /// 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)
+ {
+ 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/PropertiesFile.cs b/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs
new file mode 100644
index 0000000000..ed9300e1d3
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/PropertiesFile.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+internal static class PropertiesFile
+{
+ public static async Task> ReadAsync(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"git.properties not found at: {path}");
+ }
+
+ var map = new Dictionary();
+
+ foreach (string line in await File.ReadAllLinesAsync(path, Encoding.UTF8, TestContext.Current.CancellationToken))
+ {
+ if (!line.StartsWith("git.", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ int equalsIndex = line.IndexOf('=');
+
+ if (equalsIndex < 0)
+ {
+ continue;
+ }
+
+ map[line[..equalsIndex]] = line[(equalsIndex + 1)..];
+ }
+
+ return map;
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs
new file mode 100644
index 0000000000..c85be5bb2f
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/PublishIncludesGitPropertiesTest.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+public sealed class PublishIncludesGitPropertiesTest : GitPropertiesBuildTestBase
+{
+ [Fact]
+ public async Task Test()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ string result = await repository.TestApp.PublishAsync();
+ result.Should().NotContain("duplicate");
+
+ 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
new file mode 100644
index 0000000000..d7932ddad0
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/PublishNoBuildIncludesGitPropertiesTest.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 PublishNoBuildIncludesGitPropertiesTest : GitPropertiesBuildTestBase
+{
+ [Fact]
+ 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");
+
+ 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
new file mode 100644
index 0000000000..5328bf1595
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneInfoWhenEnableWarningsFalseTest.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;
+
+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
new file mode 100644
index 0000000000..02b3adaeca
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/ShallowCloneLeavesCommitCountsEmptyTest.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+public sealed class ShallowCloneLeavesCommitCountsEmptyTest : GitPropertiesBuildTestBase
+{
+ [Fact]
+ public async Task Test()
+ {
+ GitRepository source = await Workspace.CreateGitRepositoryAsync("source", 3);
+ await source.TagAsync("v1.0.0");
+ GitRepository shallow = await source.CloneAsShallowAsync("shallow");
+ string isShallowRepository = await shallow.RunGitAsync("rev-parse", "--is-shallow-repository");
+ isShallowRepository.Should().Be("true");
+
+ string result = await shallow.TestApp.BuildAsync();
+ result.Should().NotContain("GITPROPS001");
+ result.Should().NotContain("GITPROPS002");
+ result.AssertWarned("GITPROPS006");
+
+ 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
new file mode 100644
index 0000000000..d4cd1ceef8
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultExplicitFalseWinsOverDetectedConsumingPackageReferenceTest.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+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()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint");
+ TestProject testApp = await repository.AddTestAppReferencingAsync(dependency);
+
+ await testApp.BuildAsync("-p:GenerateGitProperties=false");
+ testApp.GitPropertiesGenerated.Should().BeFalse();
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.cs
new file mode 100644
index 0000000000..e9f63c6e34
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultGeneratesGitPropertiesWhenConsumingPackageReferencedTest.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 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()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint");
+ TestProject testApp = await repository.AddTestAppReferencingAsync(dependency);
+ await testApp.BuildAsync();
+
+ Dictionary properties = await testApp.ReadDebugPropertiesAsync();
+ string expectedCommitId = await repository.GetCommitIdAsync();
+ properties["git.commit.id"].Should().Be(expectedCommitId);
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.cs
new file mode 100644
index 0000000000..7c702cf6ac
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDetectsCustomPackageIdsTest.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 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()
+ {
+ const string customPackageId = "Example.Package.Name";
+
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ TestProject dependency = await repository.AddDependencyProjectAsync(customPackageId);
+ TestProject testApp = await repository.AddTestAppReferencingAsync(dependency);
+ await testApp.BuildAsync($"-p:GitPropertiesConsumingPackageIds={customPackageId}");
+
+ Dictionary properties = await testApp.ReadDebugPropertiesAsync();
+ string expectedCommitId = await repository.GetCommitIdAsync();
+ properties["git.commit.id"].Should().Be(expectedCommitId);
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.cs
new file mode 100644
index 0000000000..f90e2d358a
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideDoesNotMatchPackageIdAsPrefixTest.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 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()
+ {
+ const string shortPackageId = "Some";
+ const string longerPackageId = "Some2";
+
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ TestProject dependency = await repository.AddDependencyProjectAsync(longerPackageId);
+ TestProject testApp = await repository.AddTestAppReferencingAsync(dependency);
+ await testApp.BuildAsync($"-p:GitPropertiesConsumingPackageIds={shortPackageId}");
+ testApp.GitPropertiesGenerated.Should().BeFalse();
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs
new file mode 100644
index 0000000000..69c7b9e7f7
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultOverrideEmptyPackageIdsViaGlobalPropertySkipsGenerationGracefullyTest.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+namespace Steeltoe.Management.GitProperties.Build.Test;
+
+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 Test()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 1);
+ TestProject dependency = await repository.AddDependencyProjectAsync("Steeltoe.Management.Endpoint");
+ TestProject testApp = await repository.AddTestAppReferencingAsync(dependency);
+ await testApp.BuildAsync("-p:GitPropertiesConsumingPackageIds=");
+ testApp.GitPropertiesGenerated.Should().BeFalse();
+ }
+}
diff --git a/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.cs
new file mode 100644
index 0000000000..88e97c4f5e
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/SmartDefaultSkipsGenerationWhenNoConsumingPackageReferenceTest.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 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");
+ testApp.GitPropertiesGenerated.Should().BeFalse();
+ }
+}
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..01baf95bab
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/TestPaths.cs
@@ -0,0 +1,160 @@
+// 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.
+///
+///
+/// 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",
+ "shared-package.props",
+ "shared-project.props",
+ "versions.props",
+ "stylecop.json",
+ "PackageIcon.png",
+ "PackageReadme.md",
+ "Steeltoe.Debug.ruleset",
+ "Steeltoe.Release.ruleset"
+ ];
+
+ private static readonly Task RepositoryRootTask = ResolveRepositoryRootAsync();
+
+ public static Task GetRepositoryRootAsync()
+ {
+ return RepositoryRootTask;
+ }
+
+ public static async Task GetGitPropertiesBuildDirectoryAsync()
+ {
+ 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 {projectFile}.");
+ }
+
+ 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.");
+ }
+
+ ///
+ /// 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.");
+ 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/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
new file mode 100644
index 0000000000..30a8efbeec
--- /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/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.cs
new file mode 100644
index 0000000000..3e99f22002
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileProducesFallbackFileWithoutCompilingTest.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 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.");
+
+ 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.");
+ }
+}
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..995fefeaa9
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenPublishNoBuildFailsTest.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 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
new file mode 100644
index 0000000000..838414082d
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileThenSimulatedPushServerPublishUsesItTest.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 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()
+ {
+ GitRepository repository = await Workspace.CreateGitRepositoryAsync("repo", 2, true);
+ await repository.TestApp.BuildAsync("-t:WriteGitPropertiesFallbackFile");
+ Dictionary fallbackProperties = await repository.TestApp.ReadFallbackPropertiesAsync();
+
+ RemotePushProjectTree remote = repository.SimulatePush("pushed");
+ remote.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback git.properties must have survived the simulated push.");
+
+ 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 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
new file mode 100644
index 0000000000..14090d786f
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/WriteGitPropertiesFallbackFileWorksWithNoRestoreTest.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 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()
+ {
+ 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
new file mode 100644
index 0000000000..98155cdfe8
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnBuildTest.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 WriteToProjectDirectoryCreatesFallbackFileOnBuildTest : GitPropertiesBuildTestBase
+{
+ [Fact]
+ public async Task Test()
+ {
+ 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 '{repository.TestApp.FallbackFilePath}' for project '{GitPropertiesTestWorkspace.TestAppProjectName}'.");
+
+ repository.TestApp.FallbackGitPropertiesGenerated.Should().BeTrue("the fallback file should have been written next to the .csproj.");
+
+ 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.");
+
+ 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 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.");
+ }
+}
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..6573902762
--- /dev/null
+++ b/src/Management/test/GitProperties.Build.Test/WriteToProjectDirectoryCreatesFallbackFileOnPublishTest.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 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 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.");
+
+ 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.");
+
+ bool isDirty = await repository.IsDirtyAsync();
+ isDirty.Should().BeFalse("the fallback file is gitignored, so it must not show up as an untracked change.");
+ }
+}
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.*