diff --git a/src/Fallout.Build/VCS/GitRepository.cs b/src/Fallout.Build/VCS/GitRepository.cs
index b97e2c3d5..9cefab0a5 100644
--- a/src/Fallout.Build/VCS/GitRepository.cs
+++ b/src/Fallout.Build/VCS/GitRepository.cs
@@ -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;
@@ -39,22 +41,21 @@ public static GitRepository FromUrl(string url, string branch = null)
///
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,
@@ -62,12 +63,96 @@ public static GitRepository FromLocalDirectory(AbsolutePath directory)
remoteBranch);
}
+ ///
+ /// Obtains information from a local git repository.
+ ///
+ 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())
@@ -88,6 +173,9 @@ 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;
@@ -95,7 +183,16 @@ internal static string GetCommitFromHead(AbsolutePath gitDirectory, string 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();
@@ -127,17 +224,34 @@ private static IReadOnlyCollection GetTagsFromCommit(AbsolutePath gitDir
if (commit == null)
return Array.Empty();
+ 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();
+
+ 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)
@@ -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())
@@ -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,
@@ -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);
}
diff --git a/tests/Fallout.Build.Specs/GitRepositorySpecs.cs b/tests/Fallout.Build.Specs/GitRepositorySpecs.cs
index 02f326352..43337c773 100644
--- a/tests/Fallout.Build.Specs/GitRepositorySpecs.cs
+++ b/tests/Fallout.Build.Specs/GitRepositorySpecs.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using FluentAssertions;
using Fallout.Common.Git;
using Xunit;
@@ -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")]
@@ -27,7 +26,7 @@ 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);
@@ -35,18 +34,18 @@ public void FromUrlSpec(string url, string endpoint, string 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();
@@ -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()
+ .WithMessage("Failed to retrieve Git repository information");
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
}
diff --git a/tests/Fallout.Build.Specs/GitRepositoryWorktreeSpecs.cs b/tests/Fallout.Build.Specs/GitRepositoryWorktreeSpecs.cs
new file mode 100644
index 000000000..f243e2165
--- /dev/null
+++ b/tests/Fallout.Build.Specs/GitRepositoryWorktreeSpecs.cs
@@ -0,0 +1,208 @@
+using System;
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using Fallout.Common.Git;
+using Fallout.Common.IO;
+using Fallout.Common.Tooling;
+using Xunit;
+
+namespace Fallout.Common.Specs;
+
+public class GitRepositoryWorktreeSpecs
+{
+ [Fact]
+ public void Parsed_from_worktree()
+ {
+ var tempDir = GetTemporaryDirectory();
+ const string worktreeName = "test-worktree";
+ var branchName = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory()).Branch;
+ var worktreePath = CreateWorktree(tempDir, worktreeName, branchName);
+
+ try
+ {
+ var repository = GitRepository.FromLocalDirectory(worktreePath);
+ var mainRepository = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory());
+
+ AssertWorktreeRepository(repository, mainRepository, worktreePath);
+ repository.Branch.Should().StartWith($"{branchName}-test-"); // Verify branch name resolution
+ }
+ finally
+ {
+ CleanupWorktree(worktreePath);
+ CleanupTemporaryDirectory(tempDir);
+ }
+ }
+
+ [Fact]
+ public void Parsed_from_nested_worktree_directory()
+ {
+ var tempDir = GetTemporaryDirectory();
+ const string worktreeName = "nested-worktree";
+ var branchName = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory()).Branch;
+ var worktreePath = CreateWorktree(tempDir, worktreeName, branchName);
+ var nestedPath = worktreePath / "nested" / "deeply" / "nested";
+ nestedPath.CreateDirectory();
+
+ try
+ {
+ var repository = GitRepository.FromLocalDirectory(nestedPath);
+ var mainRepository = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory());
+
+ AssertWorktreeRepository(repository, mainRepository, worktreePath);
+ }
+ finally
+ {
+ CleanupWorktree(worktreePath);
+ CleanupTemporaryDirectory(tempDir);
+ }
+ }
+
+ [Fact]
+ public void Parsed_from_worktree_with_detached_head()
+ {
+ var tempDir = GetTemporaryDirectory();
+ const string worktreeName = "detached-head-worktree";
+ var branchName = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory()).Branch;
+ var worktreePath = CreateWorktree(tempDir, worktreeName, branchName);
+
+ try
+ {
+ // Get a commit hash to checkout
+ var commitHash = ProcessTasks.StartProcess("git", "rev-parse HEAD~1", workingDirectory: worktreePath)
+ .AssertZeroExitCode()
+ .Output
+ .First()
+ .Text
+ .Trim();
+
+ // Checkout the commit to create detached HEAD
+ ProcessTasks.StartProcess("git", $"checkout {commitHash}", workingDirectory: worktreePath)
+ .AssertZeroExitCode();
+
+ var repository = GitRepository.FromLocalDirectory(worktreePath);
+ var mainRepository = GitRepository.FromLocalDirectory(Directory.GetCurrentDirectory());
+
+ repository.Endpoint.Should().Be(mainRepository.Endpoint);
+ repository.Identifier.Should().Be(mainRepository.Identifier);
+ // Use string comparison to handle symlink resolution differences (e.g., /var vs /private/var)
+ repository.LocalDirectory.ToString().Should().EndWith(worktreePath.ToString().Split('/').Last());
+ repository.Branch.Should().BeNull(); // Detached HEAD should have no branch
+ repository.Head.Should().Be(commitHash);
+ repository.Commit.Should().Be(commitHash);
+ }
+ finally
+ {
+ CleanupWorktree(worktreePath);
+ CleanupTemporaryDirectory(tempDir);
+ }
+ }
+
+ [Fact]
+ public void Fails_for_worktree_with_invalid_git_file()
+ {
+ var tempDir = GetTemporaryDirectory();
+ var invalidGitDir = tempDir / "invalid-git";
+ invalidGitDir.CreateDirectory();
+
+ try
+ {
+ var gitFile = invalidGitDir / ".git";
+ gitFile.WriteAllText("invalid content without gitdir prefix");
+
+ var act = () => GitRepository.FromLocalDirectory(invalidGitDir);
+ act.Should().Throw()
+ .WithMessage("Failed to retrieve Git repository information");
+ }
+ finally
+ {
+ CleanupTemporaryDirectory(tempDir);
+ }
+ }
+
+ [Fact]
+ public void Fails_for_worktree_with_non_existent_path()
+ {
+ var tempDir = GetTemporaryDirectory();
+ var invalidGitDir = tempDir / "non-existent";
+ invalidGitDir.CreateDirectory();
+
+ try
+ {
+ var gitFile = invalidGitDir / ".git";
+ var nonExistentPath = tempDir / "does-not-exist" / ".git";
+ gitFile.WriteAllText($"gitdir: {nonExistentPath}");
+
+ var act = () => GitRepository.FromLocalDirectory(invalidGitDir);
+ act.Should().Throw()
+ .WithMessage("Failed to retrieve Git repository information");
+ }
+ finally
+ {
+ CleanupTemporaryDirectory(tempDir);
+ }
+ }
+
+ // Helper methods
+ private static AbsolutePath GetTemporaryDirectory()
+ {
+ var tempDir = AbsolutePath.Create(Path.GetTempPath()) / $"fallout-test-{Guid.NewGuid().ToString("N")[..8]}";
+ tempDir.CreateDirectory();
+ return tempDir;
+ }
+
+ private static AbsolutePath CreateWorktree(AbsolutePath tempDir, string worktreeName, string branchName)
+ {
+ var worktreePath = tempDir / worktreeName;
+ var uniqueBranchName = $"{branchName}-test-{Guid.NewGuid().ToString("N")[..8]}";
+
+ // Create a new branch from the specified branch and checkout in worktree
+ ProcessTasks.StartProcess("git", $"worktree add -b {uniqueBranchName} {worktreePath} {branchName}", workingDirectory: Directory.GetCurrentDirectory())
+ .AssertZeroExitCode();
+
+ return worktreePath;
+ }
+
+ private static void CleanupWorktree(AbsolutePath worktreePath)
+ {
+ if (!worktreePath.DirectoryExists())
+ return;
+
+ try
+ {
+ ProcessTasks.StartProcess("git", $"worktree remove {worktreePath}", workingDirectory: Directory.GetCurrentDirectory())
+ .AssertZeroExitCode();
+ }
+ catch
+ {
+ // If git worktree remove fails, force delete the directory
+ worktreePath.DeleteDirectory();
+ }
+ }
+
+ private static void CleanupTemporaryDirectory(AbsolutePath tempDir)
+ {
+ if (!tempDir.DirectoryExists())
+ return;
+
+ try
+ {
+ tempDir.DeleteDirectory();
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ private static void AssertWorktreeRepository(GitRepository worktreeRepo, GitRepository mainRepo, AbsolutePath expectedWorktreePath)
+ {
+ worktreeRepo.Endpoint.Should().Be(mainRepo.Endpoint);
+ worktreeRepo.Identifier.Should().Be(mainRepo.Identifier);
+ // Use string comparison to handle symlink resolution differences (e.g., /var vs /private/var)
+ worktreeRepo.LocalDirectory.ToString().Should().EndWith(expectedWorktreePath.ToString().Split('/').Last());
+ worktreeRepo.Head.Should().NotBeNullOrEmpty();
+ worktreeRepo.Commit.Should().NotBeNullOrEmpty();
+ worktreeRepo.Tags.Should().NotBeNull();
+ }
+}