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 */ }
+ }
+ }
}