Skip to content

Commit fca2ab4

Browse files
authored
[Test Optimization] Retrieve head commit info (#7285)
1 parent 6c257aa commit fca2ab4

File tree

13 files changed

+952
-109
lines changed

13 files changed

+952
-109
lines changed

tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,28 @@ public string? GitSearchFolder
8989

9090
public string[]? NodeLabels { get; protected set; }
9191

92-
public string? HeadCommit { get; protected set; }
93-
9492
public string? PrBaseCommit { get; protected set; }
9593

9694
public string? PrBaseBranch { get; protected set; }
9795

9896
public string? PrNumber { get; protected set; }
9997

98+
public string? HeadCommit { get; protected set; }
99+
100+
public string? HeadAuthorName { get; protected set; }
101+
102+
public string? HeadAuthorEmail { get; protected set; }
103+
104+
public DateTimeOffset? HeadAuthorDate { get; protected set; }
105+
106+
public string? HeadCommitterName { get; protected set; }
107+
108+
public string? HeadCommitterEmail { get; protected set; }
109+
110+
public DateTimeOffset? HeadCommitterDate { get; protected set; }
111+
112+
public string? HeadMessage { get; protected set; }
113+
100114
public CodeOwners? CodeOwners { get; protected set; }
101115

102116
public Dictionary<string, string?>? VariablesToBypass { get; protected set; }
@@ -269,10 +283,17 @@ public void DecorateSpan(Span span)
269283
SetTagIfNotNullOrEmpty(span, CommonTags.CINodeLabels, Datadog.Trace.Vendors.Newtonsoft.Json.JsonConvert.SerializeObject(nodeLabels));
270284
}
271285

272-
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommit, HeadCommit);
273286
SetTagIfNotNullOrEmpty(span, CommonTags.GitPrBaseCommit, PrBaseCommit);
274287
SetTagIfNotNullOrEmpty(span, CommonTags.GitPrBaseBranch, PrBaseBranch);
275288
SetTagIfNotNullOrEmpty(span, CommonTags.PrNumber, PrNumber);
289+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommit, HeadCommit);
290+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitAuthorDate, HeadAuthorDate?.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", CultureInfo.InvariantCulture));
291+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitAuthorName, HeadAuthorName);
292+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitAuthorEmail, HeadAuthorEmail);
293+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitCommitterDate, HeadCommitterDate?.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", CultureInfo.InvariantCulture));
294+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitCommitterName, HeadCommitterName);
295+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitCommitterEmail, HeadCommitterEmail);
296+
SetTagIfNotNullOrEmpty(span, CommonTags.GitHeadCommitMessage, HeadMessage);
276297

277298
if (VariablesToBypass is { } variablesToBypass)
278299
{

tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValuesGenerics.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,29 @@ protected override void Setup(IGitInfo gitInfo)
162162
Log.Warning("Git commit in .git folder is different from the one in the environment variables. [{GitCommit} != {EnvVarCommit}]", gitInfo.Commit, Commit);
163163
}
164164

165+
// **********
166+
// Get all head commit information.
167+
// **********
168+
if (!StringUtil.IsNullOrEmpty(HeadCommit))
169+
{
170+
// fetching commit data from head commit
171+
if (GitCommandHelper.FetchCommitData(WorkspacePath ?? Environment.CurrentDirectory, HeadCommit) is { } commitData &&
172+
commitData.CommitSha == HeadCommit)
173+
{
174+
HeadAuthorDate = commitData.AuthorDate;
175+
HeadAuthorEmail = commitData.AuthorEmail;
176+
HeadAuthorName = commitData.AuthorName;
177+
HeadCommitterDate = commitData.CommitterDate;
178+
HeadCommitterEmail = commitData.CommitterEmail;
179+
HeadCommitterName = commitData.CommitterName;
180+
HeadMessage = commitData.CommitMessage;
181+
}
182+
else
183+
{
184+
Log.Warning("Error fetching data for git commit '{HeadCommit}'", HeadCommit);
185+
}
186+
}
187+
165188
// **********
166189
// Expand ~ in Paths
167190
// **********

tracer/src/Datadog.Trace/Ci/GitCommandHelper.cs

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,208 @@ bool IsDefaultBranch(string candidate) => !StringUtil.IsNullOrWhiteSpace(default
424424
candidate == $"{remoteName}/{defaultBranch}");
425425
}
426426

427+
/// <summary>
428+
/// Retrieves commit metadata for the specified <paramref name="commitSha"/>. If the repository is a shallow clone, the
429+
/// method attempts to un‑shallow it (requires Git &gt;= 2.27) so that the commit information is available.
430+
/// </summary>
431+
/// <param name="workingDirectory">Path to the git working directory.</param>
432+
/// <param name="commitSha">Commit SHA to retrieve.</param>
433+
/// <returns>A populated <see cref="CommitData"/> on success; <c>null</c> otherwise.</returns>
434+
public static CommitData? FetchCommitData(string workingDirectory, string commitSha)
435+
{
436+
try
437+
{
438+
// 1. Detect shallow repository
439+
Log.Debug("GitCommandHelper.FetchCommitData: checking if the repository is a shallow clone");
440+
var isShallowClone = IsShallowCloneRepository(workingDirectory);
441+
442+
// 2. If shallow, un‑shallow it (git fetch) provided we have a modern git version
443+
if (isShallowClone)
444+
{
445+
Log.Debug("GitCommandHelper.FetchCommitData: checking the git version");
446+
var (major, minor, patch) = GetGitVersion(workingDirectory);
447+
Log.Debug<int, int, int>("GitCommandHelper.FetchCommitData: git version detected {Major}.{Minor}.{Patch}", major, minor, patch);
448+
if (major > 2 || (major == 2 && minor >= 27))
449+
{
450+
// Retrieve remote name, fallback to "origin" if not set
451+
var remoteName = GetRemoteName(workingDirectory);
452+
Log.Debug("GitCommandHelper.FetchCommitData: remote name: {Remote}", remoteName);
453+
454+
// git fetch --update-shallow --filter="blob:none" --recurse-submodules=no --no-write-fetch-head <remoteName> <commitSha>
455+
var fetchArgs =
456+
$"fetch --update-shallow --filter=\"blob:none\" --recurse-submodules=no --no-write-fetch-head {remoteName} {commitSha}";
457+
var fetchOutput = RunGitCommand(
458+
workingDirectory,
459+
fetchArgs,
460+
MetricTags.CIVisibilityCommands.Fetch);
461+
462+
if (fetchOutput is null || fetchOutput.ExitCode != 0)
463+
{
464+
Log.Warning("GitCommandHelper.FetchCommitData: git fetch failed. Exit={ExitCode}, Error={Error}", fetchOutput?.ExitCode, fetchOutput?.Error);
465+
return null;
466+
}
467+
}
468+
else
469+
{
470+
return null;
471+
}
472+
}
473+
474+
// 3. Get commit details via `git show`
475+
Log.Debug("GitCommandHelper.FetchCommitData: fetching commit details for {Commit}", commitSha);
476+
// Example output:
477+
// '1f808ea4e7c068a149975e1851bd905cef56779c|,|1753691341|,|Tony Redondo|,|tony.redondo@datadoghq.com|,|1753691341|,|GitHub|,|noreply@github.com|,|Merge branch 'master' into tony/topt-get-head-commit-info'
478+
var showArgs = $"""show {commitSha} -s --format='%H|,|%at|,|%an|,|%ae|,|%ct|,|%cn|,|%ce|,|%B'""";
479+
var showOutput = RunGitCommand(
480+
workingDirectory,
481+
showArgs,
482+
MetricTags.CIVisibilityCommands.GetHead);
483+
484+
if (showOutput is null || showOutput.ExitCode != 0 || string.IsNullOrWhiteSpace(showOutput.Output))
485+
{
486+
Log.Warning("GitCommandHelper.FetchCommitData: git show failed. Exit={ExitCode}, Error={Error}", showOutput?.ExitCode, showOutput?.Error);
487+
return null;
488+
}
489+
490+
// 4. Parse output
491+
// The delimiter is |,| to avoid issues with commit messages that may contain commas.
492+
var gitLogDataArray = showOutput.Output.Trim().Split(["|,|"], StringSplitOptions.None);
493+
if (gitLogDataArray.Length < 8)
494+
{
495+
Log.Warning<int>("GitCommandHelper.FetchCommitData: unexpected git show output – expected ≥ 8 tokens, got {Count}", gitLogDataArray.Length);
496+
return null;
497+
}
498+
499+
// Parse author and committer dates from Unix timestamp
500+
if (!long.TryParse(gitLogDataArray[1], out var authorUnixDate))
501+
{
502+
Log.Warning("Error parsing author date from git log output");
503+
return null;
504+
}
505+
506+
if (!long.TryParse(gitLogDataArray[4], out var committerUnixDate))
507+
{
508+
Log.Warning("Error parsing committer date from git log output");
509+
return null;
510+
}
511+
512+
var commit = gitLogDataArray[0];
513+
if (commit.StartsWith("'"))
514+
{
515+
commit = commit.Substring(1);
516+
}
517+
518+
// The commit message may contain the `|,|` string , so we join the remaining parts.
519+
var commitMessage = gitLogDataArray.Length > 8 ? string.Join("|,|", gitLogDataArray.Skip(7)).Trim() : gitLogDataArray[7].Trim();
520+
if (commitMessage.EndsWith("'"))
521+
{
522+
commitMessage = commitMessage.Substring(0, commitMessage.Length - 1).Trim();
523+
}
524+
525+
var commitData = new CommitData(
526+
CommitSha: commit,
527+
AuthorDate: DateTimeOffset.FromUnixTimeSeconds(authorUnixDate),
528+
AuthorName: gitLogDataArray[2],
529+
AuthorEmail: gitLogDataArray[3],
530+
CommitterDate: DateTimeOffset.FromUnixTimeSeconds(committerUnixDate),
531+
CommitterName: gitLogDataArray[5],
532+
CommitterEmail: gitLogDataArray[6],
533+
CommitMessage: commitMessage);
534+
535+
Log.Debug("GitCommandHelper.FetchCommitData: completed successfully for {Commit}", commitSha);
536+
return commitData;
537+
}
538+
catch (Exception ex)
539+
{
540+
Log.Warning(ex, "GitCommandHelper.FetchCommitData: unexpected error while fetching data for {Commit}", commitSha);
541+
return null;
542+
}
543+
}
544+
545+
/// <summary>
546+
/// Determines whether the repository located at <paramref name="workingDirectory"/> is a shallow clone.
547+
/// </summary>
548+
/// <param name="workingDirectory">Path to the git working directory.</param>
549+
/// <returns>True if the repository is a shallow clone; otherwise, false.</returns>
550+
public static bool IsShallowCloneRepository(string workingDirectory)
551+
{
552+
// We need to check if the git clone is a shallow one before uploading anything.
553+
// In the case is a shallow clone we need to reconfigure it to upload the git tree
554+
// without blobs so no content will be downloaded.
555+
var gitRevParseShallowOutput = RunGitCommand(
556+
workingDirectory,
557+
"rev-parse --is-shallow-repository",
558+
MetricTags.CIVisibilityCommands.CheckShallow);
559+
if (gitRevParseShallowOutput is null || gitRevParseShallowOutput.ExitCode != 0)
560+
{
561+
Log.Warning("GitCommandHelper: 'git rev-parse --is-shallow-repository' command is null or exit code is not 0. Exit={ExitCode}", gitRevParseShallowOutput?.ExitCode);
562+
return false;
563+
}
564+
565+
return gitRevParseShallowOutput.Output.IndexOf("true", StringComparison.OrdinalIgnoreCase) > -1;
566+
}
567+
568+
/// <summary>
569+
/// Parses and returns the git version (major, minor, patch) as reported by <c>git --version</c>.
570+
/// </summary>
571+
/// <param name="workingDirectory">Path to the git working directory.</param>
572+
/// <returns>VersionInfo containing the major, minor, and patch version numbers.</returns>
573+
public static VersionInfo GetGitVersion(string workingDirectory)
574+
{
575+
var output = RunGitCommand(
576+
workingDirectory,
577+
"--version",
578+
MetricTags.CIVisibilityCommands.GetBranch);
579+
580+
if (output is { ExitCode: 0 } && !string.IsNullOrWhiteSpace(output.Output))
581+
{
582+
// Expected format: "git version 2.41.0" or similar
583+
var span = output.Output.AsSpan().Trim();
584+
var lastSpace = span.LastIndexOf(' ');
585+
var versionText = span.Slice(lastSpace + 1).ToString();
586+
var segments = versionText.Split('.');
587+
int.TryParse(segments.ElementAtOrDefault(0), out var major);
588+
int.TryParse(segments.ElementAtOrDefault(1), out var minor);
589+
int.TryParse(segments.ElementAtOrDefault(2), out var patch);
590+
return new VersionInfo(major, minor, patch);
591+
}
592+
593+
return new VersionInfo(0, 0, 0);
594+
}
595+
596+
/// <summary>
597+
/// Attempts to obtain the default remote name for the repository located at <paramref name="workingDirectory"/>.
598+
/// Falls back to <c>origin</c> when no remote could be detected.
599+
/// </summary>
600+
/// <param name="workingDirectory">Path to the git working directory.</param>
601+
/// <returns>Remote name, or <c>origin</c> if not set.</returns>
602+
public static string GetRemoteName(string workingDirectory)
603+
{
604+
var output = RunGitCommand(
605+
workingDirectory,
606+
"config --default origin --get clone.defaultRemoteName",
607+
MetricTags.CIVisibilityCommands.GetRemote);
608+
609+
return output?.Output?.Replace("\n", string.Empty).Trim() ?? "origin";
610+
}
611+
612+
/// <summary>
613+
/// Retrieves a list of local commits from the repository located at <paramref name="workingDirectory"/>.
614+
/// </summary>
615+
/// <param name="workingDirectory">Path to the git working directory.</param>
616+
/// <returns>String array containing commit SHAs, or an empty array if no commits are found.</returns>
617+
public static string[] GetLocalCommits(string workingDirectory)
618+
{
619+
var gitLogOutput = RunGitCommand(workingDirectory, "log --format=%H -n 1000 --since=\"1 month ago\"", MetricTags.CIVisibilityCommands.GetLocalCommits);
620+
if (gitLogOutput is null)
621+
{
622+
Log.Warning("TestOptimizationClient: 'git log...' command is null");
623+
return [];
624+
}
625+
626+
return gitLogOutput.Output.Split(["\n"], StringSplitOptions.RemoveEmptyEntries);
627+
}
628+
427629
private static void CheckAndFetchBranch(string workingDirectory, string branch, string remoteName)
428630
{
429631
try
@@ -480,4 +682,16 @@ private static string[] SplitLines(string text, StringSplitOptions options = Str
480682
private readonly record struct LineRange(int Start, int End);
481683

482684
private readonly record struct BranchMetrics(string Branch, string MergeBaseSha, int Behind, int Ahead);
685+
686+
public readonly record struct VersionInfo(int Major, int Minor, int Patch);
687+
688+
public readonly record struct CommitData(
689+
string CommitSha,
690+
DateTimeOffset AuthorDate,
691+
string AuthorName,
692+
string AuthorEmail,
693+
DateTimeOffset CommitterDate,
694+
string CommitterName,
695+
string CommitterEmail,
696+
string CommitMessage);
483697
}

tracer/src/Datadog.Trace/Ci/Net/TestOptimizationClient.GetCommitsAsync.cs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,7 @@ internal sealed partial class TestOptimizationClient
2626

2727
public async Task<SearchCommitResponse> GetCommitsAsync()
2828
{
29-
var gitLogOutput = GitCommandHelper.RunGitCommand(_workingDirectory, "log --format=%H -n 1000 --since=\"1 month ago\"", MetricTags.CIVisibilityCommands.GetLocalCommits);
30-
if (gitLogOutput is null)
31-
{
32-
Log.Warning("TestOptimizationClient: 'git log...' command is null");
33-
return new SearchCommitResponse(null, null, false);
34-
}
35-
36-
var localCommits = gitLogOutput.Output.Split(["\n"], StringSplitOptions.RemoveEmptyEntries);
29+
var localCommits = GitCommandHelper.GetLocalCommits(_workingDirectory);
3730
if (localCommits.Length == 0)
3831
{
3932
Log.Debug("TestOptimizationClient: Local commits not found. (since 1 month ago)");

tracer/src/Datadog.Trace/Ci/Net/TestOptimizationClient.GetTestManagementTests.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ public async Task<TestManagementResponse> GetTestManagementTests()
2929
return new TestManagementResponse();
3030
}
3131

32-
var commitMessage = _testOptimization.CIValues.Message ?? string.Empty;
32+
var commitSha = _testOptimization.CIValues.HeadCommit ?? _commitSha;
33+
var commitMessage = _testOptimization.CIValues.HeadMessage ?? _testOptimization.CIValues.Message ?? string.Empty;
3334
_testManagementUrl ??= GetUriFromPath(TestManagementUrlPath);
3435
var query = new DataEnvelope<Data<TestManagementQuery>>(
3536
new Data<TestManagementQuery>(
36-
_commitSha,
37+
commitSha,
3738
TestManagementType,
38-
new TestManagementQuery(_repositoryUrl, _commitSha, null, commitMessage)),
39+
new TestManagementQuery(_repositoryUrl, commitSha, null, commitMessage)),
3940
null);
4041

4142
var jsonQuery = JsonConvert.SerializeObject(query, SerializerSettings);

tracer/src/Datadog.Trace/Ci/Net/TestOptimizationClient.UploadRepositoryChangesAsync.cs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,7 @@ public async Task<long> UploadRepositoryChangesAsync()
4343

4444
try
4545
{
46-
// We need to check if the git clone is a shallow one before uploading anything.
47-
// In the case is a shallow clone we need to reconfigure it to upload the git tree
48-
// without blobs so no content will be downloaded.
49-
var gitRevParseShallowOutput = GitCommandHelper.RunGitCommand(_workingDirectory, "rev-parse --is-shallow-repository", MetricTags.CIVisibilityCommands.CheckShallow);
50-
if (gitRevParseShallowOutput is null)
51-
{
52-
Log.Warning("TestOptimizationClient: 'git rev-parse --is-shallow-repository' command is null");
53-
return 0;
54-
}
55-
56-
var isShallow = gitRevParseShallowOutput.Output.IndexOf("true", StringComparison.OrdinalIgnoreCase) > -1;
46+
var isShallow = GitCommandHelper.IsShallowCloneRepository(_workingDirectory);
5747
if (!isShallow)
5848
{
5949
// Repo is not in a shallow state, we continue with the pack files upload with the initial commit data we retrieved earlier.
@@ -83,9 +73,7 @@ public async Task<long> UploadRepositoryChangesAsync()
8373
// `git fetch --shallow-since="1 month ago" --update-shallow --filter="blob:none" --recurse-submodules=no $(git config --default origin --get clone.defaultRemoteName) $(git rev-parse HEAD)`
8474
// ***
8575

86-
// git config --default origin --get clone.defaultRemoteName
87-
var originNameOutput = GitCommandHelper.RunGitCommand(_workingDirectory, "config --default origin --get clone.defaultRemoteName", MetricTags.CIVisibilityCommands.GetRemote);
88-
var originName = originNameOutput?.Output?.Replace("\n", string.Empty).Trim() ?? "origin";
76+
var originName = GitCommandHelper.GetRemoteName(_workingDirectory);
8977

9078
// git rev-parse HEAD
9179
var headOutput = GitCommandHelper.RunGitCommand(_workingDirectory, "rev-parse HEAD", MetricTags.CIVisibilityCommands.GetHead);

0 commit comments

Comments
 (0)