Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 150 additions & 14 deletions src/Fallout.Build/VCS/GitRepository.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Fallout.Common.CI;
using Fallout.Common.IO;
using Fallout.Common.Tooling;
using Fallout.Common.Utilities;

namespace Fallout.Common.Git;
Expand Down Expand Up @@ -39,35 +41,118 @@ public static GitRepository FromUrl(string url, string branch = null)
/// </summary>
public static GitRepository FromLocalDirectory(AbsolutePath directory)
{
var rootDirectory = directory.FindParentOrSelf(x => x.ContainsDirectory(".git")).NotNull($"No parent Git directory for '{directory}'");
var gitDirectory = rootDirectory / ".git";
var metadata = GetGitMetadata(directory);

var head = GetHead(gitDirectory);
var head = metadata.Head;
var branch = (GetBranchFromCI() ?? GetHeadIfAttached(head))?.TrimStart("refs/heads/").TrimStart("origin/");
var commit = GetCommitFromCI() ?? GetCommitFromHead(gitDirectory, head);
var tags = GetTagsFromCommit(gitDirectory, commit);
var (remoteName, remoteBranch) = GetRemoteNameAndBranch(gitDirectory, branch);
var (protocol, endpoint, identifier) = GetRemoteConnectionFromConfig(gitDirectory, remoteName ?? FallbackRemoteName);
var commit = GetCommitFromCI() ?? GetCommitFromHead(metadata.GitDirectory, head);
var tags = GetTagsFromCommit(metadata.GitDirectory, commit);
var (remoteName, remoteBranch) = GetRemoteNameAndBranch(metadata.GitDirectory, branch);
var (protocol, endpoint, identifier) = GetRemoteConnectionFromConfig(metadata.GitDirectory, remoteName ?? FallbackRemoteName);

return new GitRepository(
protocol,
endpoint,
identifier,
branch,
rootDirectory,
metadata.RootDirectory,
head,
commit,
tags,
remoteName,
remoteBranch);
}

/// <summary>
/// Obtains information from a local git repository.
/// </summary>
private static GitMetadata GetGitMetadata(AbsolutePath directory)
{
var rootDirectory = directory.FindParentOrSelf(x => x.ContainsDirectory(".git") || x.ContainsFile(".git"));

if (rootDirectory is not null)
{
var gitDirectory = rootDirectory / ".git";
if (File.Exists(gitDirectory))
{
var content = File.ReadAllText(gitDirectory).Trim();
if (content.StartsWith("gitdir:"))
{
var path = content.Substring("gitdir:".Length).Trim();
gitDirectory = Path.IsPathRooted(path) ? path : rootDirectory / path;
}
}

if (Directory.Exists(gitDirectory))
{
var head = GetHead(gitDirectory);
return new GitMetadata(rootDirectory, gitDirectory, head);
}
}

var worktreeInfo = GetWorktreeInfoFromGit(directory);
return worktreeInfo ?? throw new InvalidOperationException("No Git repository found");
}

private static GitMetadata GetWorktreeInfoFromGit(AbsolutePath directory)
{
try
{
// Get all information in one call
var process = ProcessTasks.StartProcess("git", "rev-parse --show-toplevel --git-common-dir --symbolic-full-name HEAD", workingDirectory: directory, logOutput: false);
process.AssertZeroExitCode();

var lines = process.Output
.Where(o => o.Type == OutputType.Std)
.Select(o => o.Text.Trim())
.ToArray();

if (lines.Length < 3)
{
throw new InvalidOperationException($"Expected 3 lines from 'git rev-parse --show-toplevel --git-common-dir --symbolic-full-name HEAD' but got {lines.Length} lines: [{string.Join(", ", lines.Select(l => $"'{l}'"))}]");
}

var rootDirectory = lines[0].Trim();
var gitDirectory = lines[1].Trim();
var head = lines[2].Trim();

// For detached HEAD, --symbolic-full-name HEAD returns "HEAD"
// In this case, get the actual commit SHA
if (head == "HEAD")
{
var commitProcess = ProcessTasks.StartProcess("git", "rev-parse HEAD", workingDirectory: directory, logOutput: false);
commitProcess.AssertZeroExitCode();

head = commitProcess.Output
.Where(o => o.Type == OutputType.Std)
.Select(o => o.Text.Trim())
.FirstOrDefault();
}

return new GitMetadata(rootDirectory, gitDirectory, head);
}
catch (ProcessException ex)
{
throw new InvalidOperationException("Failed to retrieve Git repository information", ex);
}
}

private static (string Name, string Branch) GetRemoteNameAndBranch(AbsolutePath gitDirectory, string branch)
{
if (branch == null)
return (null, null);

var configFile = gitDirectory / "config";
if (!configFile.Exists())
{
var commonDir = GetGitCommonDirectory(gitDirectory);
if (commonDir != null)
configFile = commonDir / "config";
}

if (!configFile.Exists())
return (null, null);

var configFileContent = configFile.ReadAllLines();
var data = configFileContent
.Select(x => x.Trim())
Expand All @@ -88,14 +173,26 @@ internal static string GetHeadIfAttached(string head)

internal static string GetCommitFromHead(AbsolutePath gitDirectory, string head)
{
if (head == null)
return null;

if (!head.StartsWith("refs/heads/"))
return head;

var headRefFile = gitDirectory / head;
if (headRefFile.Exists())
return headRefFile.ReadAllLines().First();

var commonDir = GetGitCommonDirectory(gitDirectory);
if (commonDir != null)
{
headRefFile = commonDir / head;
if (headRefFile.Exists())
return headRefFile.ReadAllLines().First();
}

var commit = GetPackedRefs(gitDirectory)
.Concat(commonDir != null ? GetPackedRefs(commonDir) : Array.Empty<(string, string)>())
.Where(x => x.Reference == head)
.Select(x => x.Commit)
.FirstOrDefault();
Expand Down Expand Up @@ -127,17 +224,34 @@ private static IReadOnlyCollection<string> GetTagsFromCommit(AbsolutePath gitDir
if (commit == null)
return Array.Empty<string>();

var commonDir = GetGitCommonDirectory(gitDirectory);

var packedTags = GetPackedRefs(gitDirectory)
.Concat(commonDir != null ? GetPackedRefs(commonDir) : Array.Empty<(string, string)>())
.Where(x => x.Commit == commit && x.Reference.StartsWithOrdinalIgnoreCase("refs/tags"))
.Select(x => x.Reference.TrimStart("refs/tags/"));

var tagsDirectory = gitDirectory / "refs" / "tags";
var localTags = tagsDirectory
.GlobFiles("**/*")
.Where(x => x.ReadAllText().Trim() == commit)
.Select(x => tagsDirectory.GetUnixRelativePathTo(x).ToString());

return localTags.Concat(packedTags).ToList();
var localTags = tagsDirectory.Exists()
? tagsDirectory
.GlobFiles("**/*")
.Where(x => x.ReadAllText().Trim() == commit)
.Select(x => tagsDirectory.GetUnixRelativePathTo(x).ToString())
: Array.Empty<string>();

if (commonDir != null)
{
var commonTagsDirectory = commonDir / "refs" / "tags";
if (commonTagsDirectory.Exists())
{
localTags = localTags.Concat(commonTagsDirectory
.GlobFiles("**/*")
.Where(x => x.ReadAllText().Trim() == commit)
.Select(x => commonTagsDirectory.GetUnixRelativePathTo(x).ToString()));
}
}

return localTags.Concat(packedTags).Distinct().ToList();
}

private static IEnumerable<(string Commit, string Reference)> GetPackedRefs(AbsolutePath gitDirectory)
Expand Down Expand Up @@ -170,6 +284,16 @@ private static (GitProtocol? Protocol, string Endpoint, string Identifier) GetRe
string remote)
{
var configFile = gitDirectory / "config";
if (!configFile.Exists())
{
var commonDir = GetGitCommonDirectory(gitDirectory);
if (commonDir != null)
configFile = commonDir / "config";
}

if (!configFile.Exists())
return (null, null, null);

var configFileContent = configFile.ReadAllLines();
var url = configFileContent
.Select(x => x.Trim())
Expand All @@ -186,6 +310,16 @@ private static (GitProtocol? Protocol, string Endpoint, string Identifier) GetRe
return GetRemoteConnectionFromUrl(url);
}

private static AbsolutePath GetGitCommonDirectory(AbsolutePath gitDirectory)
{
var commondirFile = gitDirectory / "commondir";
if (!commondirFile.Exists())
return null;

var path = commondirFile.ReadAllText().Trim();
return Path.IsPathRooted(path) ? path : gitDirectory / path;
}

public GitRepository(
GitProtocol? protocol,
string endpoint,
Expand Down Expand Up @@ -265,4 +399,6 @@ public override string ToString()
{
return (Protocol == GitProtocol.Https ? HttpsUrl : SshUrl).TrimEnd(".git");
}

private record GitMetadata(AbsolutePath RootDirectory, AbsolutePath GitDirectory, string Head);
}
40 changes: 29 additions & 11 deletions tests/Fallout.Build.Specs/GitRepositorySpecs.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.IO;
using System.Linq;
using FluentAssertions;
using Fallout.Common.Git;
using Xunit;
Expand All @@ -10,12 +9,12 @@ namespace Fallout.Common.Specs;
public class GitRepositorySpecs
{
[Theory]
[InlineData("https://github.com/nuke-build", "github.com", "nuke-build")]
[InlineData("https://github.com/nuke-build/", "github.com", "nuke-build")]
[InlineData("https://github.com/nuke-build/nuke", "github.com", "nuke-build/nuke")]
[InlineData("https://github.com/nuke-build/nuke.git", "github.com", "nuke-build/nuke")]
[InlineData("https://user:pass@github.com/nuke-build/nuke.git", "github.com", "nuke-build/nuke")]
[InlineData(" https://github.com/TdMxm/nuke.git", "github.com", "TdMxm/nuke")]
[InlineData("https://github.com/fallout-build", "github.com", "fallout-build")]
[InlineData("https://github.com/fallout-build/", "github.com", "fallout-build")]
[InlineData("https://github.com/fallout-build/fallout", "github.com", "fallout-build/fallout")]
[InlineData("https://github.com/fallout-build/fallout.git", "github.com", "fallout-build/fallout")]
[InlineData("https://user:pass@github.com/fallout-build/fallout.git", "github.com", "fallout-build/fallout")]
[InlineData(" https://github.com/TdMxm/fallout.git", "github.com", "TdMxm/fallout")]
[InlineData("git@git.test.org:test", "git.test.org", "test")]
[InlineData("git@git.test.org/test", "git.test.org", "test")]
[InlineData("git@git.test.org/test/", "git.test.org", "test")]
Expand All @@ -27,26 +26,26 @@ public class GitRepositorySpecs
[InlineData("https://git.test.org:1234/test/test", "git.test.org", "test/test")]
[InlineData("git://git.test.org:1234/test/test", "git.test.org", "test/test")]
[InlineData("git://git.test.org/test/test", "git.test.org", "test/test")]
public void FromUrlSpec(string url, string endpoint, string identifier)
public void Parsed_from_url(string url, string endpoint, string identifier)
{
var repository = GitRepository.FromUrl(url);
repository.Endpoint.Should().Be(endpoint);
repository.Identifier.Should().Be(identifier);
}

[Theory]
[InlineData("https://github.com/nuke-build", GitProtocol.Https)]
[InlineData("https://github.com/fallout-build", GitProtocol.Https)]
[InlineData("git@git.test.org:test", GitProtocol.Ssh)]
[InlineData("ssh://git.test.org:1234/test/test", GitProtocol.Ssh)]
[InlineData("git://git.test.org:1234/test/test", GitProtocol.Ssh)]
public void FromUrlProtocolSpec(string url, GitProtocol protocol)
public void Parsed_from_url_with_protocol(string url, GitProtocol protocol)
{
var repository = GitRepository.FromUrl(url);
repository.Protocol.Should().Be(protocol);
}

[Fact]
public void FromDirectorySpec()
public void Parsed_from_directory()
{
var repository = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory()).NotNull();
repository.Endpoint.Should().NotBeNullOrEmpty();
Expand All @@ -56,4 +55,23 @@ public void FromDirectorySpec()
repository.Commit.Should().NotBeNullOrEmpty();
repository.Tags.Should().NotBeNull();
}

[Fact]
public void Fails_for_non_git_directory()
{
var tempDir = $"{Path.GetTempPath()}fallout-test-{Guid.NewGuid().ToString("N")[..8]}";
Directory.CreateDirectory(tempDir);

try
{
var act = () => GitRepository.FromLocalDirectory(tempDir);
act.Should().Throw<InvalidOperationException>()
.WithMessage("Failed to retrieve Git repository information");
}
finally
{
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, recursive: true);
}
}
}
Loading
Loading