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(); + } +}