diff --git a/docs/configuration/configuration-reference.md b/docs/configuration/configuration-reference.md index 78c6703..225ff40 100644 --- a/docs/configuration/configuration-reference.md +++ b/docs/configuration/configuration-reference.md @@ -36,6 +36,7 @@ Xping SDK supports multiple configuration methods with the following priority or | `UploadTimeout` | TimeSpan | `30s` | `XPING_UPLOADTIMEOUT` | HTTP request timeout | | `CollectNetworkMetrics` | bool | `true` | `XPING_COLLECTNETWORKMETRICS` | Network metrics collection | | `EnablePullRequestDetection` | bool | `true` | `XPING_ENABLEPULLREQUESTDETECTION` | Detect PR context for CI/CD comment posting | +| `CollectLocalGitAuthor` | bool | `false` | `XPING_COLLECTLOCALGITAUTHOR` | Include git author name in local-run metadata (opt-in to avoid PII collection) | | `StrictMode` | bool | `false` | `XPING_STRICTMODE` | Throw on configuration errors instead of silently disabling | --- @@ -671,6 +672,47 @@ XpingContext.Initialize(config); --- +### CollectLocalGitAuthor + +**Type:** `bool` +**Default:** `false` +**Environment Variable:** `XPING_COLLECTLOCALGITAUTHOR` + +When running on a developer machine (not a CI environment) inside a git repository, controls whether the SDK reads the author name from `.git/config [user] name` and includes it as the `Git.Actor` custom property in environment metadata. + +This setting is **disabled by default** because `user.name` in `.git/config` is typically a developer's real full name — collecting and uploading it without explicit consent is a PII concern. Enable it only when your team is aware and has agreed to share this information. + +**When enabled, the following custom property is populated:** +- `Git.Actor` — value of `[user] name` from the local `.git/config` + +**This setting has no effect when:** +- Running in a CI environment (`IsCIEnvironment = true`) — CI actor comes from the CI provider's environment variables instead +- Running outside a git repository + +**Example:** + +```json +{ + "Xping": { + "CollectLocalGitAuthor": true + } +} +``` + +```bash +export XPING_COLLECTLOCALGITAUTHOR="true" +``` + +```csharp +var config = new XpingConfiguration +{ + CollectLocalGitAuthor = true +}; +XpingContext.Initialize(config); +``` + +--- + ## Advanced Settings > **Logging:** The SDK uses `Microsoft.Extensions.Logging.ILogger` for diagnostics. Configure log verbosity through your host's standard logging configuration (e.g., `appsettings.json` `Logging` section or `ILoggingBuilder`). There are no SDK-specific `LogLevel` or `Logger` configuration properties. @@ -837,7 +879,8 @@ export XPING_SAMPLINGRATE="0.1" "SamplingRate": 1.0, "UploadTimeout": "00:00:30", "CollectNetworkMetrics": true, - "EnablePullRequestDetection": true + "EnablePullRequestDetection": true, + "CollectLocalGitAuthor": false } } ``` diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs index a164355..8aa58f9 100644 --- a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs +++ b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs @@ -126,6 +126,14 @@ public string Environment /// public bool EnablePullRequestDetection { get; set; } = true; + /// + /// Gets or sets a value indicating whether to include the local git author name + /// (read from .git/config [user] name) in environment metadata when running + /// outside a CI environment. + /// Disabled by default to prevent unintentional collection of developer PII. + /// + public bool CollectLocalGitAuthor { get; set; } + /// /// Gets or sets a value indicating whether strict mode is enabled. /// When , configuration errors cause the SDK to throw a diff --git a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs index 30d81c8..f23380d 100644 --- a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs +++ b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs @@ -542,6 +542,11 @@ private static void BindEnvironmentVariablesWithPrefix(XpingConfiguration config && bool.TryParse(enablePr, out var pr)) config.EnablePullRequestDetection = pr; + // Local Git Options + if (GetEnv("COLLECTLOCALGITAUTHOR") is { } collectGitAuthor + && bool.TryParse(collectGitAuthor, out var cga)) + config.CollectLocalGitAuthor = cga; + // Strict Mode Options if (GetEnv("STRICTMODE") is { } strictMode && bool.TryParse(strictMode, out var sm)) @@ -574,6 +579,7 @@ private static void CopyConfiguration(XpingConfiguration source, XpingConfigurat target.UploadTimeout = source.UploadTimeout; target.CollectNetworkMetrics = source.CollectNetworkMetrics; target.EnablePullRequestDetection = source.EnablePullRequestDetection; + target.CollectLocalGitAuthor = source.CollectLocalGitAuthor; target.StrictMode = source.StrictMode; } diff --git a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs index 7fb974a..82b5e28 100644 --- a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs +++ b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs @@ -415,16 +415,29 @@ private bool DetectIsContainer() private Dictionary CollectCustomProperties(string operatingSystem, CIPlatform? ciPlatform, bool isContainer) { - Dictionary properties = new Dictionary(); + Dictionary properties = new() + { + ["ExecutionContext"] = ciPlatform.HasValue + ? XpingConfiguration.DefaultCiEnvironment + : XpingConfiguration.DefaultEnvironment + }; - properties["ExecutionContext"] = ciPlatform.HasValue - ? XpingConfiguration.DefaultCiEnvironment - : XpingConfiguration.DefaultEnvironment; if (!ciPlatform.HasValue && !isContainer) { properties["IsDeveloperMachine"] = "true"; } + // Detect whether running inside a git repository + string? gitDir = FindGitDirectory(); + bool isInsideGitRepository = gitDir is not null; + properties["IsInsideGitRepository"] = isInsideGitRepository ? "true" : "false"; + + // Collect local git metadata when not running in a CI environment + if (!ciPlatform.HasValue && gitDir is not null) + { + CollectLocalGitMetadata(properties, gitDir, _configuration.CollectLocalGitAuthor); + } + // Add container information if (isContainer) { @@ -572,6 +585,247 @@ private Dictionary CollectCustomProperties(string operatingSyste return properties; } + private static string? FindGitDirectory() + { + try + { + var dir = new DirectoryInfo(System.Environment.CurrentDirectory); + while (dir is not null) + { + string candidate = Path.Combine(dir.FullName, ".git"); + + if (Directory.Exists(candidate)) + return candidate; + + // Worktrees and submodules: .git is a file containing "gitdir: " + if (File.Exists(candidate)) + { + string content = File.ReadAllText(candidate).Trim(); + const string gitdirPrefix = "gitdir: "; + if (content.StartsWith(gitdirPrefix, StringComparison.Ordinal)) + { + string gitdirPath = content.Substring(gitdirPrefix.Length).Trim(); + if (!Path.IsPathRooted(gitdirPath)) + gitdirPath = Path.GetFullPath(Path.Combine(dir.FullName, gitdirPath)); + if (Directory.Exists(gitdirPath)) + return gitdirPath; + } + } + + dir = dir.Parent; + } + return null; + } + catch + { + return null; + } + } + + private static void CollectLocalGitMetadata(Dictionary properties, string gitDir, bool includeAuthor) + { + try + { + string headPath = Path.Combine(gitDir, "HEAD"); + if (!File.Exists(headPath)) + return; + + string headContent = File.ReadAllText(headPath).Trim(); + const string headsRefPrefix = "ref: refs/heads/"; + const string refAnnotation = "ref: "; + bool isDetachedHead; + string? branch; + string? sha; + + if (headContent.StartsWith(headsRefPrefix, StringComparison.Ordinal)) + { + // Normal branch checkout + isDetachedHead = false; + branch = headContent.Substring(headsRefPrefix.Length); + sha = ResolveCommitSha(gitDir, branch); + } + else if (headContent.StartsWith(refAnnotation, StringComparison.Ordinal)) + { + // Symbolic ref to a non-branch (tag, remote, etc.) — not detached HEAD, + // but we cannot safely resolve a branch name or SHA without following the full ref chain. + isDetachedHead = false; + branch = null; + sha = null; + } + else if (IsValidSha(headContent)) + { + // Raw SHA — truly detached HEAD + isDetachedHead = true; + branch = null; + sha = headContent; + } + else + { + // Unrecognized HEAD format — skip metadata collection + return; + } + + if (isDetachedHead) + { + properties["IsDetachedHead"] = "true"; + } + + if (branch is not null) + { + AddIfNotNull(properties, "Git.Branch", branch); + } + + AddIfNotNull(properties, "Git.SHA", sha); + + if (includeAuthor) + { + string? authorName = ReadGitConfigUserName(gitDir); + AddIfNotNull(properties, "Git.Actor", authorName); + } + + bool? hasStagedChanges = DetectStagedChanges(gitDir, branch); + if (hasStagedChanges.HasValue) + { + properties["HasStagedChanges"] = hasStagedChanges.Value ? "true" : "false"; + } + } + catch + { + // Never throw from environment detection methods + } + } + + private static string? ResolveCommitSha(string gitDir, string branch) + { + try + { + string refFilePath = Path.Combine(gitDir, "refs", "heads", branch); + if (File.Exists(refFilePath)) + { + return File.ReadAllText(refFilePath).Trim(); + } + + // Fall back to packed-refs + string packedRefsPath = Path.Combine(gitDir, "packed-refs"); + if (File.Exists(packedRefsPath)) + { + string target = $"refs/heads/{branch}"; + foreach (string line in File.ReadAllLines(packedRefsPath)) + { + if (line.EndsWith(target, StringComparison.Ordinal)) + { + int spaceIndex = line.IndexOf(' '); + if (spaceIndex > 0) + { + return line.Substring(0, spaceIndex); + } + } + } + } + + return null; + } + catch + { + return null; + } + } + + private static string? ReadGitConfigUserName(string gitDir) + { + try + { + string configPath = Path.Combine(gitDir, "config"); + if (!File.Exists(configPath)) + return null; + + bool inUserSection = false; + foreach (string line in File.ReadAllLines(configPath)) + { + string trimmed = line.Trim(); + + if (trimmed == "[user]") + { + inUserSection = true; + continue; + } + + if (trimmed.StartsWith("[", StringComparison.Ordinal) && inUserSection) + { + break; + } + + if (inUserSection) + { + int eqIndex = trimmed.IndexOf('='); + if (eqIndex < 0) + { + continue; + } + + string key = trimmed.Substring(0, eqIndex).Trim(); + if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase)) + { + return trimmed.Substring(eqIndex + 1).Trim(); + } + } + } + + return null; + } + catch + { + return null; + } + } + + // Heuristic: compares the mtime of .git/index (updated on git add) against the mtime of + // the last-commit ref file. Returns true if the index appears newer than the last commit, + // suggesting staged-but-not-yet-committed changes. Does NOT detect unstaged working-tree + // edits. Property key is "HasStagedChanges" to reflect this limitation accurately. + private static bool? DetectStagedChanges(string gitDir, string? branch) + { + try + { + string indexFile = Path.Combine(gitDir, "index"); + if (!File.Exists(indexFile)) + return null; + + DateTime indexModified = File.GetLastWriteTimeUtc(indexFile); + + // Try loose ref first + if (branch is not null) + { + string loosePath = Path.Combine(gitDir, "refs", "heads", branch); + if (File.Exists(loosePath)) + return indexModified > File.GetLastWriteTimeUtc(loosePath); + } + + // Fall back to packed-refs mtime as a conservative proxy + string packedRefsPath = Path.Combine(gitDir, "packed-refs"); + if (File.Exists(packedRefsPath)) + return indexModified > File.GetLastWriteTimeUtc(packedRefsPath); + + return null; + } + catch + { + return null; + } + } + + private static bool IsValidSha(string value) + { + if (value.Length is not 40 and not 64) + return false; + foreach (char c in value) + { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + return false; + } + return true; + } + private static void AddIfNotNull(Dictionary dictionary, string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) diff --git a/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs b/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs index 300a4d3..488de92 100644 --- a/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs +++ b/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs @@ -661,4 +661,19 @@ public void BindEnvVars_CIENVIRONMENTNAME_ShouldOverrideConfiguredValue() Assert.Equal("BuildPipeline", bound.CiEnvironmentName); } + + [Fact] + public void BindEnvVars_COLLECTLOCALGITAUTHOR_ShouldParseBool() + { + using var _key = WithEnv("XPING_APIKEY", "k"); + using var _proj = WithEnv("XPING_PROJECTID", "p"); + using var _ = WithEnv("XPING_COLLECTLOCALGITAUTHOR", "true"); + + var services = new ServiceCollection(); + services.AddXpingConfigurationFromConfiguration(InMemoryXpingConfig()); + var bound = services.BuildServiceProvider() + .GetRequiredService>().Value; + + Assert.True(bound.CollectLocalGitAuthor); + } } diff --git a/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs index 5fbd672..3542c06 100644 --- a/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs +++ b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs @@ -90,6 +90,223 @@ public async Task BuildEnvironmentInfoAsync_WithGitHubActions_CapturesNormalized Assert.DoesNotContain("IsDeveloperMachine", info.CustomProperties.Keys); } + [Fact] + public async Task BuildEnvironmentInfoAsync_InsideGitRepository_SetsIsInsideGitRepositoryTrue() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/main"); + tempGit.WriteRef("main", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("true", info.CustomProperties["IsInsideGitRepository"]); + Assert.Equal("main", info.CustomProperties["Git.Branch"]); + Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", info.CustomProperties["Git.SHA"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_OutsideGitRepository_SetsIsInsideGitRepositoryFalse() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempDir = new TempEmptyDirectory(); + using var dirRestorer = new WorkingDirectoryRestorer(tempDir.Path); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("false", info.CustomProperties["IsInsideGitRepository"]); + Assert.DoesNotContain("Git.Branch", info.CustomProperties.Keys); + Assert.DoesNotContain("Git.SHA", info.CustomProperties.Keys); + Assert.DoesNotContain("Git.Actor", info.CustomProperties.Keys); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithDetachedHead_SetsIsDetachedHeadTrueAndNoBranch() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + const string detachedSha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + tempGit.WriteHead(detachedSha); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("true", info.CustomProperties["IsInsideGitRepository"]); + Assert.Equal("true", info.CustomProperties["IsDetachedHead"]); + Assert.Equal(detachedSha, info.CustomProperties["Git.SHA"]); + Assert.DoesNotContain("Git.Branch", info.CustomProperties.Keys); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithCIEnvironment_CIBranchPopulatedFromEnvVarNotGit() + { + using var githubActions = new EnvRestorer("GITHUB_ACTIONS", "true"); + using var githubHeadRef = new EnvRestorer("GITHUB_HEAD_REF", "feature/ci-branch"); + using var githubSha = new EnvRestorer("GITHUB_SHA", "cafebabe"); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.True(info.IsCIEnvironment); + Assert.Equal("feature/ci-branch", info.CustomProperties["CI.Branch"]); + Assert.Equal("cafebabe", info.CustomProperties["CI.SHA"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_InsideGitRepositoryWithUserConfig_SetsActorFromGitConfig() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/main"); + tempGit.WriteRef("main", "0000000000000000000000000000000000000001"); + tempGit.WriteConfig("[user]\n\tname = Jane Doe\n\temail = jane@example.com\n"); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(new XpingConfiguration { CollectLocalGitAuthor = true }); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("Jane Doe", info.CustomProperties["Git.Actor"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithUserConfigButAuthorCollectionDisabled_OmitsActor() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/main"); + tempGit.WriteRef("main", "0000000000000000000000000000000000000001"); + tempGit.WriteConfig("[user]\n\tname = Jane Doe\n\temail = jane@example.com\n"); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); // CollectLocalGitAuthor defaults to false + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.DoesNotContain("Git.Actor", info.CustomProperties.Keys); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithPackedRefsOnly_ResolvesShaFromPackedRefs() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/release"); + tempGit.WritePackedRefs("# pack-refs with: peeled fully-peeled sorted\naaaa1111bbbb2222cccc3333dddd4444eeee5555 refs/heads/release\n"); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("release", info.CustomProperties["Git.Branch"]); + Assert.Equal("aaaa1111bbbb2222cccc3333dddd4444eeee5555", info.CustomProperties["Git.SHA"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithGitWorktree_DetectsRepositoryViaGitFile() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var mainGit = new TempGitDirectory(); + mainGit.WriteHead("ref: refs/heads/main"); + mainGit.WriteRef("main", "1234567890abcdef1234567890abcdef12345678"); + + // Simulate a worktree: create a separate directory with a .git FILE pointing to the main gitdir + string worktreeRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(worktreeRoot); + try + { + await File.WriteAllTextAsync( + Path.Combine(worktreeRoot, ".git"), + $"gitdir: {mainGit.GitDir}\n"); + + using var dirRestorer = new WorkingDirectoryRestorer(worktreeRoot); + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("true", info.CustomProperties["IsInsideGitRepository"]); + Assert.Equal("main", info.CustomProperties["Git.Branch"]); + } + finally + { + try { Directory.Delete(worktreeRoot, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithIndexNewerThanRef_SetsStagedChangesTrue() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/main"); + tempGit.WriteRef("main", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); + tempGit.WriteIndex(); + var past = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var recent = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + tempGit.SetFileTime(Path.Combine("refs", "heads", "main"), past); + tempGit.SetFileTime("index", recent); // index newer than ref + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("true", info.CustomProperties["HasStagedChanges"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithIndexOlderThanRef_SetsStagedChangesFalse() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/main"); + tempGit.WriteRef("main", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); + tempGit.WriteIndex(); + var past = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var recent = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + tempGit.SetFileTime("index", past); // index older than ref + tempGit.SetFileTime(Path.Combine("refs", "heads", "main"), recent); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("false", info.CustomProperties["HasStagedChanges"]); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithNoIndexFile_OmitsStagedChanges() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/heads/main"); + tempGit.WriteRef("main", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); + // no index file written + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.DoesNotContain("HasStagedChanges", info.CustomProperties.Keys); + } + + [Fact] + public async Task BuildEnvironmentInfoAsync_WithNonBranchSymbolicRef_DoesNotSetDetachedHead() + { + using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables); + using var tempGit = new TempGitDirectory(); + tempGit.WriteHead("ref: refs/tags/v1.0"); + using var dirRestorer = new WorkingDirectoryRestorer(tempGit.WorkingDirectory); + + IEnvironmentDetector detector = CreateDetector(); + EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync(); + + Assert.Equal("true", info.CustomProperties["IsInsideGitRepository"]); + Assert.DoesNotContain("IsDetachedHead", info.CustomProperties.Keys); + Assert.DoesNotContain("Git.Branch", info.CustomProperties.Keys); + Assert.DoesNotContain("Git.SHA", info.CustomProperties.Keys); + } + private static EnvironmentDetector CreateDetector(XpingConfiguration? configuration = null) { XpingConfiguration resolvedConfiguration = configuration ?? new XpingConfiguration(); @@ -139,4 +356,65 @@ public void Dispose() } } } + + private sealed class WorkingDirectoryRestorer : IDisposable + { + private readonly string _original; + + public WorkingDirectoryRestorer(string newDirectory) + { + _original = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(newDirectory); + } + + public void Dispose() => Directory.SetCurrentDirectory(_original); + } + + private sealed class TempEmptyDirectory : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + + public TempEmptyDirectory() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ } + } + } + + private sealed class TempGitDirectory : IDisposable + { + private readonly string _root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + + public string WorkingDirectory => _root; + public string GitDir => System.IO.Path.Combine(_root, ".git"); + + public TempGitDirectory() + { + Directory.CreateDirectory(System.IO.Path.Combine(GitDir, "refs", "heads")); + } + + public void WriteHead(string content) => + File.WriteAllText(System.IO.Path.Combine(GitDir, "HEAD"), content + "\n"); + + public void WriteRef(string branch, string sha) => + File.WriteAllText(System.IO.Path.Combine(GitDir, "refs", "heads", branch), sha + "\n"); + + public void WritePackedRefs(string content) => + File.WriteAllText(System.IO.Path.Combine(GitDir, "packed-refs"), content); + + public void WriteConfig(string content) => + File.WriteAllText(System.IO.Path.Combine(GitDir, "config"), content); + + public void WriteIndex(string content = "") => + File.WriteAllText(System.IO.Path.Combine(GitDir, "index"), content); + + public void SetFileTime(string relativePathInsideGitDir, DateTime utc) => + File.SetLastWriteTimeUtc(System.IO.Path.Combine(GitDir, relativePathInsideGitDir), utc); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } + } }