Skip to content
Closed
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageVersion Include="Glob" Version="1.1.9" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.71" />
<PackageVersion Include="Humanizer" Version="3.0.1" />
<PackageVersion Include="LibGit2Sharp" Version="0.31.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="10.0.0" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.7.115" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
Expand Down
1 change: 1 addition & 0 deletions src/Fallout.Build/Fallout.Build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="LibGit2Sharp" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
Expand Down
164 changes: 69 additions & 95 deletions src/Fallout.Build/VCS/GitRepository.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Fallout.Common.CI;
using Fallout.Common.IO;
using Fallout.Common.Utilities;
using LibGit2Sharp;

namespace Fallout.Common.Git;

Expand Down Expand Up @@ -40,14 +40,15 @@ 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 head = GetHead(gitDirectory);
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);
using var repository = new Repository(rootDirectory);

var head = GetHead(repository);
var branch = (GetBranchFromCI() ?? GetHeadBranch(repository))?.TrimStart("refs/heads/").TrimStart("origin/");
var commit = GetCommitFromCI() ?? repository.Head.Tip?.Sha;
var tags = GetTagsFromCommit(repository, commit);
var (remoteName, remoteBranch) = GetRemoteNameAndBranch(repository, branch);
var (protocol, endpoint, identifier) = GetRemoteConnectionFromConfig(repository, remoteName ?? FallbackRemoteName);

return new GitRepository(
protocol,
Expand All @@ -62,54 +63,32 @@ public static GitRepository FromLocalDirectory(AbsolutePath directory)
remoteBranch);
}

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

var configFile = gitDirectory / "config";
var configFileContent = configFile.ReadAllLines();
var data = configFileContent
.Select(x => x.Trim())
.SkipWhile(x => x != $"[branch {branch.DoubleQuote()}]")
.Skip(1)
.TakeWhile(x => !x.StartsWith("["))
.Select(x => x.Split('='))
.ToDictionary(x => x.ElementAt(0).Trim(), x => x.ElementAt(1).Trim());
return data.TryGetValue("remote", out var remote) && data.TryGetValue("merge", out var merge)
? (remote, merge.TrimStart("refs/heads/"))
: (null, null);
// Mirrors the value previously read from .git/HEAD: the symbolic ref for an
// attached head (refs/heads/<branch>), or the commit sha for a detached head.
var head = repository.Head;
return head.Reference is SymbolicReference symbolic
? symbolic.TargetIdentifier
: head.Tip?.Sha;
}

internal static string GetHeadIfAttached(string head)
private static string GetHeadBranch(Repository repository)
{
return head.StartsWith("refs/heads/") ? head : null;
var head = repository.Head;
return head.Reference is SymbolicReference symbolic ? symbolic.TargetIdentifier : null;
}

internal static string GetCommitFromHead(AbsolutePath gitDirectory, string head)
private static (string Name, string Branch) GetRemoteNameAndBranch(Repository repository, string branch)
{
if (!head.StartsWith("refs/heads/"))
return head;

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

var commit = GetPackedRefs(gitDirectory)
.Where(x => x.Reference == head)
.Select(x => x.Commit)
.FirstOrDefault();

commit.NotNull("Could not find commit information");
if (branch == null)
return (null, null);

return commit;
}
var trackedBranch = repository.Branches[branch]?.TrackedBranch;
if (trackedBranch == null || !trackedBranch.IsRemote)
return (null, null);

private static string GetHead(AbsolutePath gitDirectory)
{
var headFile = gitDirectory / "HEAD";
Assert.FileExists(headFile);
return headFile.ReadAllText().TrimStart("ref: ").Trim();
return (trackedBranch.RemoteName, trackedBranch.UpstreamBranchCanonicalName?.TrimStart("refs/heads/"));
}

internal static string GetBranchFromCI()
Expand All @@ -122,68 +101,63 @@ internal static string GetCommitFromCI()
return (Host.Instance as IBuildServer)?.Commit;
}

private static IReadOnlyCollection<string> GetTagsFromCommit(AbsolutePath gitDirectory, string commit)
private static IReadOnlyCollection<string> GetTagsFromCommit(Repository repository, string commit)
{
if (commit == null)
return Array.Empty<string>();

var packedTags = GetPackedRefs(gitDirectory)
.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();
return repository.Tags
.Where(x => x.Target.Sha == commit)
.Select(x => x.FriendlyName)
.ToList();
}

private static IEnumerable<(string Commit, string Reference)> GetPackedRefs(AbsolutePath gitDirectory)
private static (GitProtocol? Protocol, string Endpoint, string Identifier) GetRemoteConnectionFromConfig(
Repository repository,
string remote)
{
var packedRefsFile = gitDirectory / "packed-refs";
if (!packedRefsFile.Exists())
return Array.Empty<(string Commit, string Reference)>();

return packedRefsFile.ReadAllLines()
.Where(x => !x.StartsWith("#") && !x.StartsWith("^"))
.Select(x => x.Split(' '))
.Select(x => (Commit: x[0], Reference: x[1]));
var url = repository.Network.Remotes[remote]?.Url;
return url == null
? (null, null, null)
: GetRemoteConnectionFromUrl(url);
}

private static (GitProtocol Protocol, string Endpoint, string Identifier) GetRemoteConnectionFromUrl(string url)
{
var regex = new Regex(
@"^(?'protocol'\w+)?(\:\/\/)?(?>(?'user'.*)@)?(?'endpoint'[^\/:]+)(?>\:(?'port'\d+))?[\/:](?'identifier'.*?)\/?(?>\.git)?$");
var match = regex.Match(url.NotNull().Trim());

Assert.True(match.Success, $"Url '{url}' could not be parsed.");
var protocol = match.Groups["protocol"].Value.EqualsOrdinalIgnoreCase(GitProtocol.Https.ToString())
? GitProtocol.Https
: GitProtocol.Ssh;
return (protocol, match.Groups["endpoint"].Value, match.Groups["identifier"].Value);
url = url.NotNull().Trim();

// Standard schemes (https://, ssh://, git://, http://) parse cleanly via Uri.
// SCP-like syntax (git@host:path) is not a valid Uri, so it is handled separately.
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.HostNameType != UriHostNameType.Unknown)
{
var protocol = uri.Scheme.EqualsOrdinalIgnoreCase(Uri.UriSchemeHttps)
? GitProtocol.Https
: GitProtocol.Ssh;
return (protocol, uri.Host, NormalizeIdentifier(uri.AbsolutePath));
}

return ParseScpLikeUrl(url);
}

private static (GitProtocol? Protocol, string Endpoint, string Identifier) GetRemoteConnectionFromConfig(
AbsolutePath gitDirectory,
string remote)
private static (GitProtocol Protocol, string Endpoint, string Identifier) ParseScpLikeUrl(string url)
{
// Forms: [user@]host:path or [user@]host/path (no scheme prefix).
var withoutUser = url.Contains('@') ? url.Substring(url.IndexOf('@') + 1) : url;

var separatorIndex = withoutUser.IndexOfAny(new[] { ':', '/' });
Assert.True(separatorIndex > 0, $"Url '{url}' could not be parsed.");

var endpoint = withoutUser.Substring(0, separatorIndex);
var path = withoutUser.Substring(separatorIndex + 1);

return (GitProtocol.Ssh, endpoint, NormalizeIdentifier(path));
}

private static string NormalizeIdentifier(string path)
{
var configFile = gitDirectory / "config";
var configFileContent = configFile.ReadAllLines();
var url = configFileContent
.Select(x => x.Trim())
.SkipWhile(x => x != $"[remote {remote.DoubleQuote()}]")
.Skip(1)
.TakeWhile(x => !x.StartsWith("["))
.SingleOrDefault(x => x.StartsWithOrdinalIgnoreCase("url = "))
?.Split('=').ElementAt(1)
.Trim();

if (url == null)
return (null, null, null);

return GetRemoteConnectionFromUrl(url);
return path
.Trim('/')
.TrimEnd(".git");
}

public GitRepository(
Expand Down
4 changes: 4 additions & 0 deletions tests/Fallout.Build.Tests/GitRepositoryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public class GitRepositoryTest
[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")]
[InlineData("ssh://git@github.com:22/nuke-build/nuke.git", "github.com", "nuke-build/nuke")]
[InlineData("http://git.test.org/test/test.git", "git.test.org", "test/test")]
[InlineData("file://server/share/repo.git", "server", "share/repo")]
[InlineData(@"\\server\share\repo.git", "server", "share/repo")]
public void FromUrlTest(string url, string endpoint, string identifier)
{
var repository = GitRepository.FromUrl(url);
Expand Down
Loading