File: GitDataReader\GitRepository.cs
Web Access
Project: src\src\sourcelink\src\Microsoft.Build.Tasks.Git\Microsoft.Build.Tasks.Git.csproj (Microsoft.Build.Tasks.Git)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the License.txt file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;

namespace Microsoft.Build.Tasks.Git
{
    internal sealed class GitRepository : IDisposable
    {
        private const string CommonDirFileName = "commondir";
        private const string GitDirName = ".git";
        private const string GitDirPrefix = "gitdir: ";

        // See https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-HEAD
        internal const string GitHeadFileName = "HEAD";

        private const string GitModulesFileName = ".gitmodules";

        public GitConfig Config { get; }

        public GitIgnore Ignore => _lazyIgnore.Value;

        /// <summary>
        /// Normalized full path. OS specific directory separators.
        /// </summary>
        public string GitDirectory { get; }

        /// <summary>
        /// Normalized full path. OS specific directory separators.
        /// </summary>
        public string CommonDirectory { get; }

        /// <summary>
        /// Normalized full path. OS specific directory separators. Optional.
        /// </summary>
        public string? WorkingDirectory { get; }

        public GitEnvironment Environment { get; }

        private readonly Lazy<(ImmutableArray<GitSubmodule> Submodules, ImmutableArray<string> Diagnostics)> _lazySubmodules;
        private readonly Lazy<GitIgnore> _lazyIgnore;
        private readonly Lazy<string?> _lazyHeadCommitSha;
        private readonly Lazy<string?> _lazyBranchName;
        private readonly GitReferenceResolver _referenceResolver;

        internal GitRepository(GitEnvironment environment, GitConfig config, string gitDirectory, string commonDirectory, string? workingDirectory)
        {
            NullableDebug.Assert(PathUtils.IsNormalized(gitDirectory));
            NullableDebug.Assert(PathUtils.IsNormalized(commonDirectory));
            NullableDebug.Assert(workingDirectory == null || PathUtils.IsNormalized(workingDirectory));

            Config = config;
            GitDirectory = gitDirectory;
            CommonDirectory = commonDirectory;
            WorkingDirectory = workingDirectory;
            Environment = environment;

            _referenceResolver = new GitReferenceResolver(gitDirectory, commonDirectory, config.ReferenceStorageFormat, config.ObjectNameFormat);
            _lazySubmodules = new(ReadSubmodules);
            _lazyIgnore = new(LoadIgnore);
            _lazyHeadCommitSha = new(ReadHeadCommitSha);
            _lazyBranchName = new(ReadBranchName);
        }

        // test only
        internal GitRepository(
            GitEnvironment environment,
            GitConfig config,
            string gitDirectory,
            string commonDirectory,
            string? workingDirectory,
            ImmutableArray<GitSubmodule> submodules,
            ImmutableArray<string> submoduleDiagnostics,
            GitIgnore ignore,
            string? headCommitSha,
            string? branchName)
            : this(environment, config, gitDirectory, commonDirectory, workingDirectory)
        {
            _lazySubmodules = new(() => (submodules, submoduleDiagnostics));
            _lazyIgnore = new(() => ignore);
            _lazyHeadCommitSha = new(() => headCommitSha);
            _lazyBranchName = new(() => branchName);
        }

        public void Dispose()
        {
            _referenceResolver.Dispose();
        }

        /// <summary>
        /// Opens a repository at the specified location.
        /// </summary>
        /// <exception cref="IOException" />
        /// <exception cref="InvalidDataException" />
        /// <exception cref="NotSupportedException">The repository found requires higher version of git repository format that is currently supported.</exception>
        /// <returns>null if no git repository can be found that contains the specified path.</returns>
        internal static GitRepository? OpenRepository(string path, GitEnvironment environment)
            => TryFindRepository(path, out var location) ? OpenRepository(location, environment) : null;

        /// <summary>
        /// Opens a repository at the specified location.
        /// </summary>
        /// <exception cref="IOException" />
        /// <exception cref="InvalidDataException" />
        /// <exception cref="NotSupportedException">The repository found requires higher version of git repository format that is currently supported.</exception>
        public static GitRepository OpenRepository(GitRepositoryLocation location, GitEnvironment environment)
        {
            NullableDebug.Assert(environment != null);
            NullableDebug.Assert(location.GitDirectory != null);
            NullableDebug.Assert(location.CommonDirectory != null);

            // See https://git-scm.com/docs/gitrepository-layout

            var config = GitConfig.ReadRepositoryConfig(location.GitDirectory, location.CommonDirectory, environment);
            var workingDirectory = GetWorkingDirectory(config, location);

            return new GitRepository(environment, config, location.GitDirectory, location.CommonDirectory, workingDirectory);
        }

        private static string? GetWorkingDirectory(GitConfig config, GitRepositoryLocation location)
        {
            // TODO (https://github.com/dotnet/sourcelink/issues/301):
            // GIT_WORK_TREE environment variable can also override working directory.

            // Working directory can be overridden by a config option.
            // See https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreworktree
            var value = config.GetVariableValue("core", "worktree");
            if (value != null)
            {
                // git does not expand home dir relative path ("~/")
                try
                {
                    return Path.GetFullPath(Path.Combine(location.GitDirectory, value));
                }
                catch
                {
                    throw new InvalidDataException(string.Format(Resources.ValueOfIsNotValidPath, "core.worktree", value));
                }
            }

            return location.WorkingDirectory;
        }

        /// <summary>
        /// Returns the commit SHA of the current HEAD tip.
        /// </summary>
        /// <exception cref="IOException"/>
        /// <exception cref="InvalidDataException"/>
        /// <returns>Null if the HEAD tip reference can't be resolved.</returns>
        public string? GetHeadCommitSha()
            => _lazyHeadCommitSha.Value;

        /// <exception cref="IOException"/>
        /// <exception cref="InvalidDataException"/>
        private string? ReadHeadCommitSha()
            => _referenceResolver.ResolveHeadReference();

        public string? GetBranchName()
            => _lazyBranchName.Value;

        private string? ReadBranchName()
            => _referenceResolver.GetBranchForHead();

        /// <summary>
        /// Creates <see cref="GitReferenceResolver"/> for a submodule located in the specified <paramref name="submoduleWorkingDirectoryFullPath"/>.
        /// </summary>
        /// <exception cref="IOException"/>
        /// <exception cref="InvalidDataException"/>
        /// <returns>Null if the submodule can't be located.</returns>
        public static GitReferenceResolver? GetSubmoduleReferenceResolver(string submoduleWorkingDirectoryFullPath, GitEnvironment environment)
        {
            // Submodules don't usually have their own .git directories but this is still legal.
            // This can occur with older versions of Git or other tools, or when a user clones one
            // repo into another's source tree (but it was not yet registered as a submodule).
            // See https://git-scm.com/docs/gitsubmodules#_forms for more details.
            var dotGitPath = Path.Combine(submoduleWorkingDirectoryFullPath, GitDirName);

            var gitDirectory =
                Directory.Exists(dotGitPath) ? dotGitPath :
                File.Exists(dotGitPath) ? ReadDotGitFile(dotGitPath) : null;

            if (gitDirectory == null || !IsGitDirectory(gitDirectory, out var commonDirectory))
            {
                return null;
            }

            var config = GitConfig.ReadRepositoryConfig(gitDirectory, commonDirectory, environment);
            return new GitReferenceResolver(gitDirectory, commonDirectory, config.ReferenceStorageFormat, config.ObjectNameFormat);
        }

        private string GetWorkingDirectory()
            => WorkingDirectory ?? throw new InvalidOperationException(Resources.RepositoryDoesNotHaveWorkingDirectory);

        /// <exception cref="IOException"/>
        /// <exception cref="InvalidDataException"/>
        /// <exception cref="NotSupportedException"/>
        public ImmutableArray<GitSubmodule> GetSubmodules()
            => _lazySubmodules.Value.Submodules;

        /// <exception cref="IOException"/>
        /// <exception cref="InvalidDataException"/>
        /// <exception cref="NotSupportedException"/>
        public ImmutableArray<string> GetSubmoduleDiagnostics()
            => _lazySubmodules.Value.Diagnostics;

        /// <exception cref="IOException"/>
        /// <exception cref="InvalidDataException"/>
        /// <exception cref="NotSupportedException"/>
        private (ImmutableArray<GitSubmodule> Submodules, ImmutableArray<string> Diagnostics) ReadSubmodules()
        {
            var workingDirectory = GetWorkingDirectory();
            var submoduleConfig = ReadSubmoduleConfig();
            if (submoduleConfig == null)
            {
                return (ImmutableArray<GitSubmodule>.Empty, ImmutableArray<string>.Empty);
            }

            ImmutableArray<string>.Builder? lazyDiagnostics = null;

            void reportDiagnostic(string diagnostic)
                => (lazyDiagnostics ??= ImmutableArray.CreateBuilder<string>()).Add(diagnostic);

            var builder = ImmutableArray.CreateBuilder<GitSubmodule>();

            foreach (var (name, path, url) in EnumerateSubmoduleConfig(submoduleConfig))
            {
                if (NullableString.IsNullOrWhiteSpace(path))
                {
                    reportDiagnostic(string.Format(Resources.InvalidSubmodulePath, name, path));
                    continue;
                }

                // Ignore unspecified URL - Source Link doesn't use it.

                string fullPath;
                try
                {
                    fullPath = Path.GetFullPath(Path.Combine(workingDirectory, path));
                }
                catch
                {
                    reportDiagnostic(string.Format(Resources.InvalidSubmodulePath, name, path));
                    continue;
                }

                string? headCommitSha;
                try
                {
                    using var resolver = GetSubmoduleReferenceResolver(fullPath, Environment);
                    if (resolver == null)
                    {
                        // If we can't locate the submodule directory then it won't have any source files
                        // and we can safely ignore the submodule.
                        continue;
                    }

                    headCommitSha = resolver.ResolveHeadReference();
                }
                catch (Exception e) when (e is IOException or InvalidDataException)
                {
                    reportDiagnostic(e.Message);
                    continue;
                }

                builder.Add(new GitSubmodule(name, path, fullPath, url, headCommitSha));
            }

            return (builder.ToImmutable(), (lazyDiagnostics != null) ? lazyDiagnostics.ToImmutable() : ImmutableArray<string>.Empty);
        }

        // internal for testing
        internal GitConfig? ReadSubmoduleConfig()
        {
            var workingDirectory = GetWorkingDirectory();
            var submodulesConfigFile = Path.Combine(workingDirectory, GitModulesFileName);
            if (!File.Exists(submodulesConfigFile))
            {
                return null;
            }

            return GitConfig.ReadSubmoduleConfig(GitDirectory, CommonDirectory, Environment, submodulesConfigFile);
        }

        // internal for testing
        internal static IEnumerable<(string Name, string? Path, string? Url)> EnumerateSubmoduleConfig(GitConfig submoduleConfig)
        {
            foreach (var group in submoduleConfig.Variables.
                Where(kvp => kvp.Key.SectionNameEquals("submodule")).
                GroupBy(kvp => kvp.Key.SubsectionName, GitVariableName.SubsectionNameComparer).
                OrderBy(group => group.Key))
            {
                var name = group.Key;
                string? url = null;
                string? path = null;

                foreach (var variable in group)
                {
                    if (variable.Key.VariableNameEquals("path"))
                    {
                        path = variable.Value.Last();
                    }
                    else if (variable.Key.VariableNameEquals("url"))
                    {
                        url = variable.Value.Last();
                    }
                }

                yield return (name, path, url);
            }
        }

        private GitIgnore LoadIgnore()
        {
            var workingDirectory = GetWorkingDirectory();
            var ignoreCase = GitConfig.ParseBooleanValue(Config.GetVariableValue("core", "ignorecase"));
            var excludesFile = Config.GetVariableValue("core", "excludesFile");
            var commonInfoExclude = Path.Combine(CommonDirectory, "info", "exclude");

            var root = GitIgnore.LoadFromFile(commonInfoExclude, GitIgnore.LoadFromFile(excludesFile, parent: null));
            return new GitIgnore(root, workingDirectory, ignoreCase);
        }

        /// <summary>
        /// Returns <see cref="GitRepositoryLocation"/> if the specified <paramref name="repositoryDirectory"/> is 
        /// a valid repository directory.
        /// </summary>
        /// <exception cref="IOException" />
        /// <exception cref="InvalidDataException" />
        /// <exception cref="NotSupportedException">The repository found requires higher version of git repository format that is currently supported.</exception>
        /// <returns>False if no git repository can be found that contains the specified path.</returns>
        public static bool TryGetRepositoryLocation(string directory, out GitRepositoryLocation location)
        {
            try
            {
                directory = Path.GetFullPath(directory);
            }
            catch
            {
                location = default;
                return false;
            }

            return TryGetRepositoryLocationImpl(directory, out location);
        }

        /// <summary>
        /// Finds a git repository containing the specified path, if any.
        /// </summary>
        /// <exception cref="IOException" />
        /// <exception cref="InvalidDataException" />
        /// <exception cref="NotSupportedException">The repository found requires higher version of git repository format that is currently supported.</exception>
        /// <returns>False if no git repository can be found that contains the specified path.</returns>
        public static bool TryFindRepository(string directory, out GitRepositoryLocation location)
        {
            var dir = directory;
            try
            {
                dir = Path.GetFullPath(dir);
            }
            catch
            {
                location = default;
                return false;
            }

            while (dir != null)
            {
                if (TryGetRepositoryLocationImpl(dir, out location))
                {
                    return true;
                }

                // TODO: https://github.com/dotnet/sourcelink/issues/302
                // stop on device boundary
                dir = Path.GetDirectoryName(dir);
            }

            location = default;
            return false;
        }

        private static bool TryGetRepositoryLocationImpl(string directory, out GitRepositoryLocation location)
        {
            string? commonDirectory;
            var dotGitPath = Path.Combine(directory, GitDirName);

            if (Directory.Exists(dotGitPath))
            {
                if (IsGitDirectory(dotGitPath, out commonDirectory))
                {
                    location = new GitRepositoryLocation(gitDirectory: dotGitPath, commonDirectory, workingDirectory: directory);
                    return true;
                }
            }
            else if (File.Exists(dotGitPath))
            {
                var link = ReadDotGitFile(dotGitPath);
                if (IsGitDirectory(link, out commonDirectory))
                {
                    location = new GitRepositoryLocation(gitDirectory: link, commonDirectory, workingDirectory: directory);
                    return true;
                }

                location = default;
                return false;
            }

            if (Directory.Exists(directory))
            {
                if (IsGitDirectory(directory, out commonDirectory))
                {
                    location = new GitRepositoryLocation(gitDirectory: directory, commonDirectory, workingDirectory: null);
                    return true;
                }
            }

            location = default;
            return false;
        }

        private static string ReadDotGitFile(string path)
        {
            string content;
            try
            {
                content = File.ReadAllText(path);
            }
            catch (Exception e) when (e is not IOException)
            {
                throw new IOException(e.Message, e);
            }

            if (!content.StartsWith(GitDirPrefix))
            {
                throw new InvalidDataException(string.Format(Resources.FormatOfFileIsInvalid, path));
            }

            var link = content[GitDirPrefix.Length..].TrimEnd(CharUtils.AsciiWhitespace);

            try
            {
                // link is relative to the directory containing the file:
                return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, link));
            }
            catch
            {
                throw new InvalidDataException(string.Format(Resources.PathSpecifiedInFileIsInvalid, path, link));
            }
        }

        private static bool IsGitDirectory(string directory, [NotNullWhen(true)] out string? commonDirectory)
        {
            // HEAD file is required
            if (!File.Exists(Path.Combine(directory, GitHeadFileName)))
            {
                commonDirectory = null;
                return false;
            }

            // Spec https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-commondir:
            var commonLinkPath = Path.Combine(directory, CommonDirFileName);
            if (File.Exists(commonLinkPath))
            {
                try
                {
                    commonDirectory = Path.Combine(directory, File.ReadAllText(commonLinkPath).TrimEnd(CharUtils.AsciiWhitespace));
                    // Normalize relative paths. For example, git worktrees typically have "../.." in this file.
                    commonDirectory = Path.GetFullPath(commonDirectory);
                }
                catch
                {
                    // git does not consider the directory valid git directory if the content of commondir file is malformed
                    commonDirectory = null;
                    return false;
                }
            }
            else
            {
                commonDirectory = directory;
            }

            // Git also requires objects and refs directories, but we allow them to be missing.
            // See https://github.com/dotnet/sourcelink/tree/master/docs#minimal-git-repository-metadata
            return Directory.Exists(commonDirectory);
        }
    }
}