diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 58960623..04ab4914 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ - Latest + Preview $(CopyrightMicrosoft) Apache-2.0 true diff --git a/src/Microsoft.Build.Tasks.Git.Operations/GitOperations.cs b/src/Microsoft.Build.Tasks.Git.Operations/GitOperations.cs index d32bcbc2..2a692f80 100644 --- a/src/Microsoft.Build.Tasks.Git.Operations/GitOperations.cs +++ b/src/Microsoft.Build.Tasks.Git.Operations/GitOperations.cs @@ -26,8 +26,13 @@ public static string LocateRepository(string directory) return Repository.Discover(directory); } + internal static IRepository CreateRepository(string root) + => new Repository(root); + public static string GetRepositoryUrl(IRepository repository, Action logWarning = null, string remoteName = null) { + // GetVariableValue("remote", name, "url"); + var remotes = repository.Network.Remotes; var remote = string.IsNullOrEmpty(remoteName) ? (remotes["origin"] ?? remotes.FirstOrDefault()) : remotes[remoteName]; if (remote == null) @@ -179,15 +184,6 @@ public static ITaskItem[] GetSourceRoots(IRepository repository, Action is the URL of the new submodule's origin repository. This may be either an absolute URL, or (if it begins with ./ or ../), - // the location relative to the superproject's default remote repository (Please note that to specify a repository foo.git which is located - // right next to a superproject bar.git, you'll have to use ../foo.git instead of ./foo.git - as one might expect when following the rules - // for relative URLs -- because the evaluation of relative URLs in Git is identical to that of relative directories). - // - // The given URL is recorded into .gitmodules for use by subsequent users cloning the superproject. - // If the URL is given relative to the superproject's repository, the presumption is the superproject and submodule repositories - // will be kept together in the same relative location, and only the superproject's URL needs to be provided.git -- - // submodule will correctly locate the submodule using the relative URL in .gitmodules. var submoduleUrl = NormalizeUrl(submodule.Url, repoRoot); if (submoduleUrl == null) { diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/CharUtils.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/CharUtils.cs new file mode 100644 index 00000000..57cc9941 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/CharUtils.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Build.Tasks.Git +{ + internal static class CharUtils + { + public static char[] AsciiWhitespace = { ' ', '\t', '\n', '\f', '\r', '\v' }; + + public static bool IsHexadecimalDigit(char c) + => c >= '0' && c <= '9' || c >= 'A' && c <= 'F' || c >= 'a' && c <= 'f'; + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitConfig.Reader.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitConfig.Reader.cs new file mode 100644 index 00000000..669b1032 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitConfig.Reader.cs @@ -0,0 +1,593 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace Microsoft.Build.Tasks.Git +{ + partial class GitConfig + { + internal class Reader + { + private const int MaxIncludeDepth = 10; + + // reused for parsing names + private readonly StringBuilder _reusableBuffer = new StringBuilder(); + + // slash terminated posix path + private readonly string _gitDirectoryPosix; + + private readonly string _commonDirectory; + private readonly Func _fileOpener; + private readonly GitEnvironment _environment; + + public Reader(string gitDirectory, string commonDirectory, GitEnvironment environment, Func fileOpener = null) + { + Debug.Assert(environment != null); + + _environment = environment; + _gitDirectoryPosix = PathUtils.ToPosixDirectoryPath(gitDirectory); + _commonDirectory = commonDirectory; + _fileOpener = fileOpener ?? File.OpenText; + } + + /// + /// + internal GitConfig Load() + { + var variables = new Dictionary>(); + + foreach (var path in EnumerateExistingConfigurationFiles()) + { + LoadVariablesFrom(path, variables, includeDepth: 0); + } + + return new GitConfig(variables.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray())); + } + + /// + /// + internal GitConfig LoadFrom(string path) + { + var variables = new Dictionary>(); + LoadVariablesFrom(path, variables, includeDepth: 0); + return new GitConfig(variables.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray())); + } + + private string GetXdgDirectory() + { + var xdgConfigHome = _environment.XdgConfigHomeDirectory; + if (xdgConfigHome != null) + { + return Path.Combine(xdgConfigHome, "git"); + } + else + { + return Path.Combine(_environment.HomeDirectory, ".config", "git"); + } + } + + internal IEnumerable EnumerateExistingConfigurationFiles() + { + // program data (Windows only) + if (_environment.ProgramDataDirectory != null) + { + var programDataConfig = Path.Combine(_environment.ProgramDataDirectory, "git", "config"); + if (File.Exists(programDataConfig)) + { + yield return programDataConfig; + } + } + + // system + var systemDir = _environment.SystemDirectory; + if (systemDir != null) + { + var systemConfig = Path.Combine(systemDir, "gitconfig"); + if (systemConfig != null) + { + yield return systemConfig; + } + } + + // XDG + var xdgConfig = Path.Combine(GetXdgDirectory(), "config"); + if (File.Exists(xdgConfig)) + { + yield return xdgConfig; + } + + // global (user home) + var globalConfig = Path.Combine(_environment.HomeDirectory, ".gitconfig"); + if (File.Exists(globalConfig)) + { + yield return globalConfig; + } + + // local + var localConfig = Path.Combine(_commonDirectory, "config"); + if (File.Exists(localConfig)) + { + yield return localConfig; + } + + // TODO: worktree config + } + + /// + /// + internal void LoadVariablesFrom(string path, Dictionary> variables, int includeDepth) + { + // https://git-scm.com/docs/git-config#_syntax + + // The following is allowed: + // [section][section]var = x + // [section]#[section] + + if (includeDepth > MaxIncludeDepth) + { + throw new InvalidDataException($"Configuration files recursion exceeded maximum allowed depth of {MaxIncludeDepth}"); + } + + TextReader reader; + + try + { + reader = _fileOpener(path); + } + catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException) + { + return; + } + catch (Exception e) when (!(e is IOException)) + { + throw new IOException(e.Message, e); + } + + using (reader) + { + string sectionName = ""; + string subsectionName = ""; + + while (true) + { + SkipMultilineWhitespace(reader); + + int c = reader.Peek(); + if (c == -1) + { + break; + } + + // Comment to the end of the line: + if (IsCommentStart(c)) + { + ReadToLineEnd(reader); + continue; + } + + if (c == '[') + { + ReadSectionHeader(reader, _reusableBuffer, out sectionName, out subsectionName); + continue; + } + + ReadVariableDeclaration(reader, _reusableBuffer, out var variableName, out var variableValue); + + // Variable declared outside of a section is allowed (has no section name prefix). + + var key = new VariableKey(sectionName, subsectionName, variableName); + if (!variables.TryGetValue(key, out var values)) + { + variables.Add(key, values = new List()); + } + + values.Add(variableValue); + + // Spec https://git-scm.com/docs/git-config#_includes: + if (IsIncludePath(key, path)) + { + string includedConfigPath = NormalizeRelativePath(relativePath: variableValue, basePath: path); + LoadVariablesFrom(includedConfigPath, variables, includeDepth + 1); + } + } + } + } + + /// + private string NormalizeRelativePath(string relativePath, string basePath) + { + string root; + if (relativePath.Length >= 2 && relativePath[0] == '~' && PathUtils.IsDirectorySeparator(relativePath[1])) + { + root = _environment.HomeDirectory; + relativePath = relativePath.Substring(2); + } + else + { + root = Path.GetDirectoryName(basePath); + } + + try + { + return Path.GetFullPath(Path.Combine(root, relativePath)); + } + catch + { + throw new InvalidDataException($"Invalid path: {relativePath}"); + } + } + + private bool IsIncludePath(VariableKey key, string configFilePath) + { + // unconditional: + if (key.Equals(new VariableKey("include", "", "path"))) + { + return true; + } + + // conditional: + if (VariableKey.SectionNameComparer.Equals(key.SectionName, "includeIf") && + VariableKey.VariableNameComparer.Equals(key.VariableName, "path") && + key.SubsectionName != "") + { + bool ignoreCase; + string pattern; + + const string caseSensitiveGitDirPrefix = "gitdir:"; + const string caseInsensitiveGitDirPrefix = "gitdir/i:"; + + if (key.SubsectionName.StartsWith(caseSensitiveGitDirPrefix, StringComparison.Ordinal)) + { + pattern = key.SubsectionName.Substring(caseSensitiveGitDirPrefix.Length); + ignoreCase = false; + } + else if (key.SubsectionName.StartsWith(caseInsensitiveGitDirPrefix, StringComparison.Ordinal)) + { + pattern = key.SubsectionName.Substring(caseInsensitiveGitDirPrefix.Length); + ignoreCase = true; + } + else + { + return false; + } + + if (pattern.Length >= 2 && (pattern[0] == '.' || pattern[0] == '~') && PathUtils.IsDirectorySeparator(pattern[1])) + { + // leading './' is substituted with the path to the directory containing the current config file. + // leading '~/' is substituted with HOME path + var root = (pattern[0] == '.') ? Path.GetDirectoryName(configFilePath) : _environment.HomeDirectory; + + pattern = PathUtils.CombinePosixPaths(PathUtils.ToPosixPath(root), pattern.Substring(2)); + } + else if (!PathUtils.IsAbsolute(pattern)) + { + pattern = "**/" + pattern; + } + + if (PathUtils.IsDirectorySeparator(pattern[pattern.Length - 1])) + { + pattern += "**"; + } + + return Glob.IsMatch(pattern, _gitDirectoryPosix, ignoreCase, matchWildCardWithDirectorySeparator: true); + } + + return false; + } + + // internal for testing + internal static void ReadSectionHeader(TextReader reader, StringBuilder reusableBuffer, out string name, out string subsectionName) + { + var nameBuilder = reusableBuffer.Clear(); + + int c = reader.Read(); + Debug.Assert(c == '['); + + while (true) + { + c = reader.Read(); + if (c == ']') + { + name = nameBuilder.ToString(); + subsectionName = ""; + break; + } + + if (IsWhitespace(c)) + { + name = nameBuilder.ToString(); + subsectionName = ReadSubsectionName(reader, reusableBuffer); + + c = reader.Read(); + if (c != ']') + { + throw new InvalidDataException(); + } + + break; + } + + if (IsAlphaNumeric(c) || c == '-' || c == '.') + { + // Allowed characters: alpha-numeric, '-', '.'; no restriction on the name start character. + nameBuilder.Append((char)c); + } + else + { + throw new InvalidDataException(); + } + } + + name = name.ToLowerInvariant(); + + // Deprecated syntax: [section.subsection] + int firstDot = name.IndexOf('.'); + if (firstDot != -1) + { + // "[.x]" parses to section "", subsection ".x" (lookup ".x.var" suceeds, ".X.var" fails) + // "[..x]" parses to section ".", subsection "x" (lookup "..x.var" suceeds, "..X.var" fails) + // "[x.]" parses to section "x.", subsection "" (lookups "X..var" and "x..var" suceed) + // "[x..]" parses to section "x", subsection "." (lookups "X...var" and "x...var" suceed) + + var prefix = (firstDot == name.Length - 1) ? name : name.Substring(0, firstDot); + var suffix = name.Substring(firstDot + 1); + + subsectionName = (subsectionName.Length > 0) ? suffix + "." + subsectionName : suffix; + name = prefix; + } + } + + private static string ReadSubsectionName(TextReader reader, StringBuilder reusableBuffer) + { + SkipWhitespace(reader); + + int c = reader.Read(); + if (c != '"') + { + throw new InvalidDataException(); + } + + var subsectionName = reusableBuffer.Clear(); + while (true) + { + c = reader.Read(); + if (c <= 0) + { + throw new InvalidDataException(); + } + + if (c == '"') + { + return subsectionName.ToString(); + } + + // Escaping: backslashes are skipped. + // Section headers can't span multiple lines. + if (c == '\\') + { + c = reader.Read(); + if (c <= 0) + { + throw new InvalidDataException(); + } + } + + subsectionName.Append((char)c); + } + } + + // internal for testing + internal static void ReadVariableDeclaration(TextReader reader, StringBuilder reusableBuffer, out string name, out string value) + { + name = ReadVariableName(reader, reusableBuffer); + if (name.Length == 0) + { + throw new InvalidDataException(); + } + + SkipWhitespace(reader); + + // Not allowed: + // name # + // = value + + int c = reader.Peek(); + if (c == -1 || IsCommentStart(c) || IsEndOfLine(c)) + { + ReadToLineEnd(reader); + + // If the value is not specified the variable is considered of type Boolean with value "true" + value = "true"; + return; + } + + if (c != '=') + { + throw new InvalidDataException(); + } + + reader.Read(); + + SkipWhitespace(reader); + + value = ReadVariableValue(reader, reusableBuffer); + } + + private static string ReadVariableName(TextReader reader, StringBuilder reusableBuffer) + { + var nameBuilder = reusableBuffer.Clear(); + int c; + + // Allowed characters: alpha-numeric, '-'; starts with alphabetic. + while (IsAlphabetic(c = reader.Peek()) || (c == '-' || IsNumeric(c)) && nameBuilder.Length > 0) + { + nameBuilder.Append((char)c); + reader.Read(); + } + + return nameBuilder.ToString().ToLowerInvariant(); + } + + private static string ReadVariableValue(TextReader reader, StringBuilder reusableBuffer) + { + // Allowed: + // name = "a"x"b" `axb` + // name = "b"#"a" `b` + // name = \ + // abc `abc` + // name = "a\ + // bc" `a bc` + // name = a\ + // bc `abc` + // name = a\ + // bc `a bc` + + // read until comment/eoln, quote + bool inQuotes = false; + var builder = reusableBuffer.Clear(); + int lengthIgnoringTrailingWhitespace = 0; + + while (true) + { + int c = reader.Read(); + if (c == -1 || IsEndOfLine(c)) + { + if (inQuotes) + { + throw new InvalidDataException(); + } + + break; + } + + if (c == '\\') + { + switch (reader.Peek()) + { + case '\r': + case '\n': + ReadToLineEnd(reader); + continue; + + case 'n': + reader.Read(); + builder.Append('\n'); + + // escaped \n is not considered trailing whitespace: + lengthIgnoringTrailingWhitespace = builder.Length; + continue; + + case 't': + reader.Read(); + builder.Append('\t'); + + // escaped \t is not considered trailing whitespace: + lengthIgnoringTrailingWhitespace = builder.Length; + continue; + + case '\\': + case '"': + builder.Append((char)reader.Read()); + lengthIgnoringTrailingWhitespace = builder.Length; + continue; + + default: + throw new InvalidDataException(); + } + } + + if (c == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (IsCommentStart(c) && !inQuotes) + { + ReadToLineEnd(reader); + break; + } + + builder.Append((char)c); + + if (!IsWhitespace(c) || inQuotes) + { + lengthIgnoringTrailingWhitespace = builder.Length; + } + } + + return builder.ToString(0, lengthIgnoringTrailingWhitespace); + } + + private static void SkipMultilineWhitespace(TextReader reader) + { + while (IsWhitespaceOrEndOfLine(reader.Peek())) + { + reader.Read(); + } + } + + private static void SkipWhitespace(TextReader reader) + { + while (IsWhitespace(reader.Peek())) + { + reader.Read(); + } + } + + private static void ReadToLineEnd(TextReader reader) + { + while (true) + { + int c = reader.Read(); + if (c == -1) + { + return; + } + + if (c == '\r') + { + if (reader.Peek() == '\n') + { + reader.Read(); + return; + } + + return; + } + + if (c == '\n') + { + return; + } + } + } + + private static bool IsCommentStart(int c) + => c == ';' || c == '#'; + + private static bool IsAlphabetic(int c) + => c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'; + + private static bool IsNumeric(int c) + => c >= '0' && c <= '9'; + + private static bool IsAlphaNumeric(int c) + => IsAlphabetic(c) || IsNumeric(c); + + private static bool IsWhitespace(int c) + => c == ' ' || c == '\t' || c == '\f' || c == '\v'; + + private static bool IsEndOfLine(int c) + => c == '\r' || c == '\n'; + + private static bool IsWhitespaceOrEndOfLine(int c) + => IsWhitespace(c) || IsEndOfLine(c); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitConfig.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitConfig.cs new file mode 100644 index 00000000..f6b9e8d2 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitConfig.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +namespace Microsoft.Build.Tasks.Git +{ + internal sealed partial class GitConfig + { + public static readonly GitConfig Empty = new GitConfig(ImmutableDictionary>.Empty); + + internal readonly struct VariableKey : IEquatable + { + public static readonly StringComparer SectionNameComparer = StringComparer.OrdinalIgnoreCase; + public static readonly StringComparer SubsectionNameComparer = StringComparer.Ordinal; + public static readonly StringComparer VariableNameComparer = StringComparer.OrdinalIgnoreCase; + + public readonly string SectionName; + public readonly string SubsectionName; + public readonly string VariableName; + + public VariableKey(string sectionName, string subsectionName, string variableName) + { + Debug.Assert(sectionName != null); + Debug.Assert(subsectionName != null); + Debug.Assert(variableName != null); + + SectionName = sectionName; + SubsectionName = subsectionName; + VariableName = variableName; + } + + public bool SectionNameEquals(string name) + => SectionNameComparer.Equals(SectionName, name); + + public bool SubsectionNameEquals(string name) + => SubsectionNameComparer.Equals(SubsectionName, name); + + public bool VariableNameEquals(string name) + => VariableNameComparer.Equals(VariableName, name); + + public bool Equals(VariableKey other) + => SectionNameEquals(other.SectionName) && + SubsectionNameEquals(other.SubsectionName) && + VariableNameEquals(other.VariableName); + + public override bool Equals(object obj) + => obj is VariableKey other && Equals(other); + + public override int GetHashCode() + => SectionName.GetHashCode() ^ SubsectionName.GetHashCode() ^ VariableName.GetHashCode(); + + public override string ToString() + => (SubsectionName.Length == 0) ? + SectionName + "." + VariableName : + SectionName + "." + SubsectionName + "." + VariableName; + } + + public readonly ImmutableDictionary> Variables; + + public GitConfig(ImmutableDictionary> variables) + { + Debug.Assert(variables != null); + Variables = variables; + } + + // for testing: + internal IEnumerable>> EnumerateVariables() + => Variables.Select(kvp => new KeyValuePair>(kvp.Key.ToString(), kvp.Value)); + + public ImmutableArray GetVariableValues(string section, string name) + => GetVariableValues(section, subsection: "", name); + + public ImmutableArray GetVariableValues(string section, string subsection, string name) + => Variables.TryGetValue(new VariableKey(section, subsection, name), out var multiValue) ? multiValue : default; + + public string GetVariableValue(string section, string name) + => GetVariableValue(section, "", name); + + public string GetVariableValue(string section, string subsection, string name) + { + var values = GetVariableValues(section, subsection, name); + return values.IsDefault ? null : values[values.Length - 1]; + } + + public static bool ParseBooleanValue(string str, bool defaultValue = false) + => TryParseBooleanValue(str, out var value) ? value : defaultValue; + + public static bool TryParseBooleanValue(string str, out bool value) + { + // https://git-scm.com/docs/git-config#Documentation/git-config.txt-boolean + + if (str == null) + { + value = false; + return false; + } + + var comparer = StringComparer.OrdinalIgnoreCase; + + if (str == "1" || comparer.Equals(str, "true") || comparer.Equals(str, "on") || comparer.Equals(str, "yes")) + { + value = true; + return true; + } + + if (str == "0" || comparer.Equals(str, "false") || comparer.Equals(str, "off") || comparer.Equals(str, "no") || str == "") + { + value = false; + return true; + } + + value = false; + return false; + } + + internal static long ParseInt64Value(string str, long defaultValue = 0) + => TryParseInt64Value(str, out var value) ? value : defaultValue; + + internal static bool TryParseInt64Value(string str, out long value) + { + if (string.IsNullOrEmpty(str)) + { + value = 0; + return false; + } + + long multiplier; + switch (str[str.Length - 1]) + { + case 'K': + case 'k': + multiplier = 1024; + break; + + case 'M': + case 'm': + multiplier = 1024 * 1024; + break; + + case 'G': + case 'g': + multiplier = 1024 * 1024 * 1024; + break; + + default: + multiplier = 1; + break; + } + + if (!long.TryParse(multiplier > 1 ? str.Substring(0, str.Length - 1) : str, out value)) + { + return false; + } + + try + { + value = checked(value * multiplier); + } + catch (OverflowException) + { + return false; + } + + return true; + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitEnvironment.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitEnvironment.cs new file mode 100644 index 00000000..4fd0d3b5 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitEnvironment.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Microsoft.Build.Tasks.Git +{ + internal sealed class GitEnvironment + { + public string HomeDirectory { get; } + public string XdgConfigHomeDirectory { get; } + public string ProgramDataDirectory { get; } + public string SystemDirectory { get; } + + // TODO: consider adding environment variables: GIT_DIR, GIT_DISCOVERY_ACROSS_FILESYSTEM, GIT_CEILING_DIRECTORIES + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITDIRcode + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITCEILINGDIRECTORIEScode + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITDISCOVERYACROSSFILESYSTEMcode + // + // if GIT_COMMON_DIR is set config worktree is ignored + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITCOMMONDIRcode + // + // GIT_WORK_TREE overrides all other work tree settings: + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITWORKTREEcode + + public GitEnvironment( + string homeDirectory, + string xdgConfigHomeDirectory = null, + string programDataDirectory = null, + string systemDirectory = null) + { + Debug.Assert(!string.IsNullOrEmpty(homeDirectory)); + + HomeDirectory = homeDirectory; + + if (!string.IsNullOrWhiteSpace(xdgConfigHomeDirectory)) + { + XdgConfigHomeDirectory = xdgConfigHomeDirectory; + } + + if (!string.IsNullOrWhiteSpace(programDataDirectory)) + { + ProgramDataDirectory = programDataDirectory; + } + + if (!string.IsNullOrWhiteSpace(systemDirectory)) + { + SystemDirectory = systemDirectory; + } + } + + public static GitEnvironment CreateFromProcessEnvironment() + { + var systemDir = PathUtils.IsUnixLikePlatform ? "/etc" : + Path.Combine(FindWindowsGitInstallation(), "mingw64", "etc"); + + return new GitEnvironment( + homeDirectory: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify), + xdgConfigHomeDirectory: Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"), + programDataDirectory: Environment.GetEnvironmentVariable("PROGRAMDATA"), + systemDirectory: systemDir); + } + + public static string FindWindowsGitInstallation() + { + Debug.Assert(!PathUtils.IsUnixLikePlatform); + + string[] paths; + try + { + paths = Environment.GetEnvironmentVariable("PATH").Split(Path.PathSeparator); + } + catch + { + paths = Array.Empty(); + } + + var gitExe = paths.FirstOrDefault(dir => File.Exists(Path.Combine(dir, "git.exe"))); + if (gitExe != null) + { + return Path.GetDirectoryName(gitExe); + } + + var gitCmd = paths.FirstOrDefault(dir => File.Exists(Path.Combine(dir, "git.cmd"))); + if (gitCmd != null) + { + return Path.GetDirectoryName(gitCmd); + } + +#if REGISTRY + string registryInstallLocation; + try + { + using var regKey = Registry.LocalMachine.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\Git_is1"); + registryInstallLocation = regKey?.GetValue("InstallLocation") as string; + } + catch + { + registryInstallLocation = null; + } + + if (registryInstallLocation != null) + { + yield return Path.Combine(registryInstallLocation, subdirectory); + } +#endif + return null; + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.Matcher.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.Matcher.cs new file mode 100644 index 00000000..f17e3283 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.Matcher.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace Microsoft.Build.Tasks.Git +{ + partial class GitIgnore + { + internal sealed class Matcher + { + public GitIgnore Ignore { get; } + + /// + /// Maps full posix slash-terminated directory name to a pattern group. + /// + private readonly Dictionary _patternGroups; + + /// + /// The result of "is ignored" for directories. + /// + private readonly Dictionary _directoryIgnoreStateCache; + + private readonly List _reusableGroupList; + + internal Matcher(GitIgnore ignore) + { + Ignore = ignore; + _patternGroups = new Dictionary(); + _directoryIgnoreStateCache = new Dictionary(Ignore.PathComparer); + _reusableGroupList = new List(); + } + + // test only: + internal IReadOnlyDictionary DirectoryIgnoreStateCache + => _directoryIgnoreStateCache; + + private PatternGroup GetPatternGroup(string directory) + { + if (_patternGroups.TryGetValue(directory, out var group)) + { + return group; + } + + PatternGroup parent; + if (directory.Equals(Ignore.WorkingDirectory, Ignore.PathComparison)) + { + parent = Ignore.Root; + } + else + { + parent = GetPatternGroup(PathUtils.ToPosixDirectoryPath(Path.GetDirectoryName(PathUtils.TrimTrailingSlash(directory)))); + } + + group = LoadFromFile(Path.Combine(directory, GitIgnoreFileName), parent) ?? parent; + + _patternGroups.Add(directory, group); + return group; + } + + /// + /// Checks if the specified file path is ignored. + /// + /// Normalized path. + /// True if the path is ignored, fale if it is not, null if it is outside of the working directory. + public bool? IsNormalizedFilePathIgnored(string fullPath) + { + if (!PathUtils.IsAbsolute(fullPath)) + { + throw new ArgumentException("Path must be absolute", nameof(fullPath)); + } + + if (PathUtils.HasTrailingDirectorySeparator(fullPath)) + { + throw new ArgumentException("Path must be a file path", nameof(fullPath)); + } + + return IsPathIgnored(PathUtils.ToPosixPath(fullPath), isDirectoryPath: false); + } + + /// + /// Checks if the specified path is ignored. + /// + /// Full path. + /// True if the path is ignored, fale if it is not, null if it is outside of the working directory. + public bool? IsPathIgnored(string fullPath) + { + if (!PathUtils.IsAbsolute(fullPath)) + { + throw new ArgumentException("Path must be absolute", nameof(fullPath)); + } + + // git uses the FS case-sensitivity for checking directory existence: + bool isDirectoryPath = PathUtils.HasTrailingDirectorySeparator(fullPath) || Directory.Exists(fullPath); + + var fullPathNoSlash = PathUtils.TrimTrailingSlash(PathUtils.ToPosixPath(Path.GetFullPath(fullPath))); + if (isDirectoryPath && fullPathNoSlash.Equals(Ignore._workingDirectoryNoSlash, Ignore.PathComparison)) + { + return false; + } + + return IsPathIgnored(fullPathNoSlash, isDirectoryPath); + } + + private bool? IsPathIgnored(string normalizedPosixPath, bool isDirectoryPath) + { + Debug.Assert(PathUtils.IsAbsolute(normalizedPosixPath)); + Debug.Assert(PathUtils.IsPosixPath(normalizedPosixPath)); + Debug.Assert(!PathUtils.HasTrailingSlash(normalizedPosixPath)); + + // paths outside of working directory: + if (!normalizedPosixPath.StartsWith(Ignore.WorkingDirectory, Ignore.PathComparison)) + { + return null; + } + + if (isDirectoryPath && _directoryIgnoreStateCache.TryGetValue(normalizedPosixPath, out var isIgnored)) + { + return isIgnored; + } + + isIgnored = IsIgnoredRecursive(normalizedPosixPath, isDirectoryPath); + if (isDirectoryPath) + { + _directoryIgnoreStateCache.Add(normalizedPosixPath, isIgnored); + } + + return isIgnored; + } + + private bool IsIgnoredRecursive(string normalizedPosixPath, bool isDirectoryPath) + { + SplitPath(normalizedPosixPath, out var directory, out var fileName); + if (directory == null || !directory.StartsWith(Ignore.WorkingDirectory, Ignore.PathComparison)) + { + return false; + } + + var isIgnored = IsIgnored(normalizedPosixPath, directory, fileName, isDirectoryPath); + if (isIgnored) + { + return true; + } + + // The target file/directory itself is not ignored, but its containing directory might be. + normalizedPosixPath = PathUtils.TrimTrailingSlash(directory); + if (_directoryIgnoreStateCache.TryGetValue(normalizedPosixPath, out isIgnored)) + { + return isIgnored; + } + + isIgnored = IsIgnoredRecursive(normalizedPosixPath, isDirectoryPath: true); + _directoryIgnoreStateCache.Add(normalizedPosixPath, isIgnored); + return isIgnored; + } + + private static void SplitPath(string fullPath, out string directoryWithSlash, out string fileName) + { + Debug.Assert(!PathUtils.HasTrailingSlash(fullPath)); + int i = fullPath.LastIndexOf('/'); + if (i < 0) + { + directoryWithSlash = null; + fileName = fullPath; + } + else + { + directoryWithSlash = fullPath.Substring(0, i + 1); + fileName = fullPath.Substring(i + 1); + } + } + + private bool IsIgnored(string normalizedPosixPath, string directory, string fileName, bool isDirectoryPath) + { + // Default patterns can't be overriden by a negative pattern: + if (fileName.Equals(".git", Ignore.PathComparison)) + { + return true; + } + + bool isIgnored = false; + + // Visit groups in reverse order. + // Patterns specified closer to the target file override those specified above. + _reusableGroupList.Clear(); + var groups = _reusableGroupList; + for (var patternGroup = GetPatternGroup(directory); patternGroup != null; patternGroup = patternGroup.Parent) + { + groups.Add(patternGroup); + } + + for (int i = groups.Count - 1; i >= 0; i--) + { + var patternGroup = groups[i]; + + if (!normalizedPosixPath.StartsWith(patternGroup.ContainingDirectory, Ignore.PathComparison)) + { + continue; + } + + string lazyRelativePath = null; + + foreach (var pattern in patternGroup.Patterns) + { + // If a pattern is matched as ignored only look for a negative pattern that matches as well. + // If a pattern is not matched then skip negative patterns. + if (isIgnored != pattern.IsNegative) + { + continue; + } + + if (pattern.IsDirectoryPattern && !isDirectoryPath) + { + continue; + } + + string matchPath = pattern.IsFullPathPattern ? + lazyRelativePath ??= normalizedPosixPath.Substring(patternGroup.ContainingDirectory.Length) : + fileName; + + if (Glob.IsMatch(pattern.Glob, matchPath, Ignore.IgnoreCase, matchWildCardWithDirectorySeparator: false)) + { + // TODO: optimize negative pattern lookup (once we match, do we need to continue matching?) + isIgnored = !pattern.IsNegative; + } + } + } + + return isIgnored; + } + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs new file mode 100644 index 00000000..9008c0bc --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace Microsoft.Build.Tasks.Git +{ + internal sealed partial class GitIgnore + { + internal sealed class PatternGroup + { + /// + /// Directory of the .gitignore file that defines the pattern. + /// Full posix slash terminated path. + /// + public readonly string ContainingDirectory; + + public readonly ImmutableArray Patterns; + + public readonly PatternGroup Parent; + + public PatternGroup(PatternGroup parent, string containingDirectory, ImmutableArray patterns) + { + Debug.Assert(PathUtils.IsPosixPath(containingDirectory)); + Debug.Assert(PathUtils.HasTrailingSlash(containingDirectory)); + + Parent = parent; + ContainingDirectory = containingDirectory; + Patterns = patterns; + } + } + + internal readonly struct Pattern + { + public readonly PatternFlags Flags; + public readonly string Glob; + + public Pattern(string glob, PatternFlags flags) + { + Debug.Assert(glob != null); + + Glob = glob; + Flags = flags; + } + + public bool IsDirectoryPattern => (Flags & PatternFlags.DirectoryPattern) != 0; + public bool IsFullPathPattern => (Flags & PatternFlags.FullPath) != 0; + public bool IsNegative => (Flags & PatternFlags.Negative) != 0; + + public override string ToString() + => $"{(IsNegative ? "!" : "")}{Glob}{(IsDirectoryPattern ? " " : "")}{(IsFullPathPattern ? " " : "")}"; + } + + [Flags] + internal enum PatternFlags + { + None = 0, + Negative = 1, + DirectoryPattern = 2, + FullPath = 4, + } + + private const string GitIgnoreFileName = ".gitignore"; + + /// + /// Full posix slash terminated path. + /// + public string WorkingDirectory { get; } + private readonly string _workingDirectoryNoSlash; + + public bool IgnoreCase { get; } + + public PatternGroup Root { get; } + + internal GitIgnore(PatternGroup root, string workingDirectory, bool ignoreCase) + { + Debug.Assert(PathUtils.IsPosixPath(workingDirectory)); + Debug.Assert(PathUtils.HasTrailingSlash(workingDirectory)); + + IgnoreCase = ignoreCase; + WorkingDirectory = workingDirectory; + _workingDirectoryNoSlash = PathUtils.TrimTrailingSlash(workingDirectory); + Root = root; + } + + private StringComparison PathComparison + => IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private IEqualityComparer PathComparer + => IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + public Matcher CreateMatcher() + => new Matcher(this); + + /// + /// is invalid + internal static PatternGroup LoadFromFile(string path, PatternGroup parent) + { + // See https://git-scm.com/docs/gitignore#_pattern_format + + if (!File.Exists(path)) + { + return null; + } + + StreamReader reader; + try + { + reader = File.OpenText(path); + } + catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException) + { + return null; + } + + var reusableBuffer = new StringBuilder(); + + var directory = PathUtils.ToPosixDirectoryPath(Path.GetFullPath(Path.GetDirectoryName(path))); + var patterns = ImmutableArray.CreateBuilder(); + + using (reader) + { + while (true) + { + string line = reader.ReadLine(); + if (line == null) + { + break; + } + + if (TryParsePattern(line, reusableBuffer, out var glob, out var flags)) + { + patterns.Add(new Pattern(glob, flags)); + } + } + } + + if (patterns.Count == 0) + { + return null; + } + + return new PatternGroup(parent, directory, patterns.ToImmutable()); + } + + internal static bool TryParsePattern(string line, StringBuilder reusableBuffer, out string glob, out PatternFlags flags) + { + glob = null; + flags = PatternFlags.None; + + // Trailing spaces are ignored unless '\'-escaped. + // Leading spaces are significant. + // Other whitespace (\t, \v, \f) is significant. + int e = line.Length - 1; + while (e >= 0 && line[e] == ' ') + { + e--; + } + + e++; + + // Skip blank line. + if (e == 0) + { + return false; + } + + // put trailing space back if escaped: + if (e < line.Length && line[e] == ' ' && line[e - 1] == '\\') + { + e++; + } + + int s = 0; + + // Skip comment. + if (line[s] == '#') + { + return false; + } + + // Pattern negation. + if (line[s] == '!') + { + flags |= PatternFlags.Negative; + s++; + } + + if (s == e) + { + return false; + } + + if (line[e - 1] == '/') + { + flags |= PatternFlags.DirectoryPattern; + e--; + } + + if (s == e) + { + return false; + } + + if (line.IndexOf('/', s, e - s) >= 0) + { + flags |= PatternFlags.FullPath; + } + + if (line[s] == '/') + { + s++; + } + + if (s == e) + { + return false; + } + + int escape = line.IndexOf('\\', s, e - s); + if (escape < 0) + { + glob = line.Substring(s, e - s); + return true; + } + + reusableBuffer.Clear(); + reusableBuffer.Append(line, s, escape - s); + + int i = escape; + while (i < e) + { + var c = line[i++]; + if (c == '\\' && i < e) + { + c = line[i++]; + } + + reusableBuffer.Append(c); + } + + glob = reusableBuffer.ToString(); + return true; + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitRepository.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitRepository.cs new file mode 100644 index 00000000..e8929138 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitRepository.cs @@ -0,0 +1,474 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Microsoft.Build.Tasks.Git +{ + internal sealed class GitRepository + { + private const int SupportedGitRepoFormatVersion = 0; + + private const string CommonDirFileName = "commondir"; + private const string GitDirName = ".git"; + private const string GitDirPrefix = "gitdir: "; + private const string GitDirFileName = "gitdir"; + + // See https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-HEAD + private const string GitHeadFileName = "HEAD"; + + private const string GitModulesFileName = ".gitmodules"; + + public GitConfig Config { get; } + + public GitIgnore Ignore => _gitIgnore.Value; + + /// + /// Full path. + /// + public string GitDirectory { get; } + + /// + /// Full path. + /// + public string CommonDirectory { get; } + + /// + /// Optional, full path. + /// + public string WorkingDirectory { get; } + + public GitEnvironment Environment { get; } + + private readonly Lazy> _submodules; + private readonly Lazy _gitIgnore; + + internal GitRepository(GitEnvironment environment, GitConfig config, string gitDirectory, string commonDirectory, string workingDirectory) + { + Debug.Assert(environment != null); + Debug.Assert(config != null); + Debug.Assert(gitDirectory != null); + Debug.Assert(commonDirectory != null); + + Config = config; + GitDirectory = gitDirectory; + CommonDirectory = commonDirectory; + WorkingDirectory = workingDirectory; + Environment = environment; + + _submodules = new Lazy>(LoadSubmoduleConfiguration); + _gitIgnore = new Lazy(LoadIgnore); + } + + /// + /// Finds a git repository contining the specified path, if any. + /// + /// + /// + /// The repository found requires higher version of git repository format that is currently supported. + /// null if no git repository can be found that contains the specified path. + public static GitRepository OpenRepository(string path, GitEnvironment environment) + { + Debug.Assert(path != null); + Debug.Assert(environment != null); + + // See https://git-scm.com/docs/gitrepository-layout + + if (!LocateRepository(path, out var gitDirectory, out var commonDirectory, out var defaultWorkingDirectory)) + { + // unable to find repository + return null; + } + + Debug.Assert(gitDirectory != null); + Debug.Assert(commonDirectory != null); + + var reader = new GitConfig.Reader(gitDirectory, commonDirectory, environment); + var config = reader.Load(); + + var workingDirectory = GetWorkingDirectory(config, gitDirectory, commonDirectory) ?? defaultWorkingDirectory; + + // See https://github.com/git/git/blob/master/Documentation/technical/repository-version.txt + string versionStr = config.GetVariableValue("core", "repositoryformatversion"); + if (GitConfig.TryParseInt64Value(versionStr, out var version) && version > SupportedGitRepoFormatVersion) + { + throw new NotSupportedException($"Unsupported repository version {versionStr}. Only versions up to {SupportedGitRepoFormatVersion} are supported."); + } + + return new GitRepository(environment, config, gitDirectory, commonDirectory, workingDirectory); + } + + // internal for testing + internal static string GetWorkingDirectory(GitConfig config, string gitDirectory, string commonDirectory) + { + // Working trees cannot have the same common directory and git directory. + // 'gitdir' file in a git directory indicates a working tree. + + var gitdirFilePath = Path.Combine(gitDirectory, GitDirFileName); + + var isLinkedWorkingTree = PathUtils.ToPosixDirectoryPath(commonDirectory) != PathUtils.ToPosixDirectoryPath(gitDirectory) && + File.Exists(gitdirFilePath); + + if (isLinkedWorkingTree) + { + // https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-worktreesltidgtgitdir + + string workingDirectory; + try + { + workingDirectory = File.ReadAllText(gitdirFilePath); + } + catch (Exception e) when (!(e is IOException)) + { + throw new IOException(e.Message, e); + } + + workingDirectory = workingDirectory.TrimEnd(CharUtils.AsciiWhitespace); + + // Path in gitdir file must be absolute. + if (!PathUtils.IsAbsolute(workingDirectory)) + { + throw new InvalidDataException($"Path specified in '{gitdirFilePath}' is not absolute."); + } + + try + { + return Path.GetFullPath(workingDirectory); + } + catch + { + throw new InvalidDataException($"Path specified in '{gitdirFilePath}' is invalid."); + } + } + + // See https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreworktree + string value = config.GetVariableValue("core", "worktree"); + if (value != null) + { + // git does not expand home dir relative path ("~/") + try + { + return Path.GetFullPath(Path.Combine(gitDirectory, value)); + } + catch + { + throw new InvalidDataException($"The value of core.worktree is not a valid path: '{value}'"); + } + } + + return null; + } + + /// + /// Returns the commit SHA of the current HEAD tip. + /// + /// + /// + /// Null if the HEAD tip reference can't be resolved. + public string GetHeadCommitSha() + => GetHeadCommitSha(GitDirectory, CommonDirectory); + + /// + /// Returns the commit SHA of the current HEAD tip of the specified submodule. + /// + /// The path to the submodule working directory relative to the working directory of this repository. + /// + /// + /// Null if the HEAD tip reference can't be resolved. + public string GetSubmoduleHeadCommitSha(string submoduleWorkingDirectory) + { + Debug.Assert(submoduleWorkingDirectory != null); + + string dotGitPath; + try + { + dotGitPath = Path.Combine(GetWorkingDirectory(), submoduleWorkingDirectory, GitDirName); + } + catch + { + throw new InvalidDataException($"Invalid module path: '{submoduleWorkingDirectory}'"); + } + + var gitDirectory = ReadDotGitFile(dotGitPath); + if (!IsGitDirectory(gitDirectory, out var commonDirectory)) + { + return null; + } + + return GetHeadCommitSha(gitDirectory, commonDirectory); + } + + /// + /// + private static string GetHeadCommitSha(string gitDirectory, string commonDirectory) + { + // See + // https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-HEAD + // https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-refs + + string headRef; + try + { + headRef = File.ReadAllText(Path.Combine(gitDirectory, GitHeadFileName)); + } + catch (Exception e) when (!(e is IOException)) + { + throw new IOException(e.Message, e); + } + + return ResolveReference(headRef, commonDirectory); + } + + // internal for testing + internal static string ResolveReference(string reference, string commonDirectory) + { + HashSet lazyVisitedReferences = null; + return ResolveReference(reference, commonDirectory, ref lazyVisitedReferences); + } + + /// + /// + private static string ResolveReference(string reference, string commonDirectory, ref HashSet lazyVisitedReferences) + { + const string refPrefix = "ref: "; + if (reference.StartsWith(refPrefix + "refs/", StringComparison.Ordinal)) + { + var symRef = reference.Substring(refPrefix.Length); + + if (lazyVisitedReferences != null && !lazyVisitedReferences.Add(symRef)) + { + // infinite recursion + throw new InvalidDataException($"Recursion detected while resolving reference: '{reference}'"); + } + + string content; + try + { + content = File.ReadAllText(Path.Combine(commonDirectory, symRef)); + } + catch (ArgumentException) + { + throw new InvalidDataException($"Invalid reference: '{reference}'"); + } + catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException) + { + return null; + } + catch (Exception e) when (!(e is IOException)) + { + throw new IOException(e.Message, e); + } + + if (IsObjectId(reference)) + { + return reference; + } + + lazyVisitedReferences ??= new HashSet(); + + return ResolveReference(content, commonDirectory, ref lazyVisitedReferences); + } + + if (IsObjectId(reference)) + { + return reference; + } + + throw new InvalidDataException($"Invalid reference: '{reference}'"); + } + + private string GetWorkingDirectory() + => WorkingDirectory ?? throw new InvalidOperationException("Repository does not have a working directory"); + + private static bool IsObjectId(string reference) + => reference.Length == 40 && reference.All(CharUtils.IsHexadecimalDigit); + + /// + /// + public ImmutableArray GetSubmodules() + => _submodules.Value; + + /// + /// + private ImmutableArray LoadSubmoduleConfiguration() + { + var submodulesConfigFile = Path.Combine(GetWorkingDirectory(), GitModulesFileName); + if (!File.Exists(submodulesConfigFile)) + { + return ImmutableArray.Empty; + } + + var builder = ImmutableArray.CreateBuilder(); + var reader = new GitConfig.Reader(GitDirectory, CommonDirectory, Environment); + var submoduleConfig = reader.LoadFrom(submodulesConfigFile); + + foreach (var group in submoduleConfig.Variables. + Where(kvp => kvp.Key.SectionNameEquals("submodule")). + GroupBy(kvp => kvp.Key.SubsectionName, GitConfig.VariableKey.SubsectionNameComparer). + OrderBy(group => 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(); + } + } + + if (path != null && url != null) + { + builder.Add(new GitSubmodule(group.Key, path, url)); + } + } + + return builder.ToImmutable(); + } + + 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); + } + + /// + /// + internal static bool LocateRepository(string directory, out string gitDirectory, out string commonDirectory, out string workingDirectory) + { + gitDirectory = commonDirectory = workingDirectory = null; + + try + { + directory = Path.GetFullPath(directory); + } + catch + { + return false; + } + + while (directory != null) + { + // TODO: stop on device boundary + + var dotGitPath = Path.Combine(directory, GitDirName); + + if (Directory.Exists(dotGitPath)) + { + if (IsGitDirectory(dotGitPath, out commonDirectory)) + { + gitDirectory = dotGitPath; + workingDirectory = directory; + return true; + } + } + else if (File.Exists(dotGitPath)) + { + var link = ReadDotGitFile(dotGitPath); + if (IsGitDirectory(link, out commonDirectory)) + { + gitDirectory = link; + workingDirectory = directory; + return true; + } + + return false; + } + + if (Directory.Exists(directory)) + { + if (IsGitDirectory(directory, out commonDirectory)) + { + gitDirectory = directory; + workingDirectory = null; + return true; + } + } + + directory = Path.GetDirectoryName(directory); + } + + return false; + } + + private static string ReadDotGitFile(string path) + { + string content; + try + { + content = File.ReadAllText(path); + } + catch (Exception e) when (!(e is IOException)) + { + throw new IOException(e.Message, e); + } + + if (!content.StartsWith(GitDirPrefix)) + { + throw new InvalidDataException($"Invalid format of '.git' file at '{path}'"); + } + + // git does not trim whitespace: + var link = content.Substring(GitDirPrefix.Length); + + try + { + // link is relative to the directory containing the file: + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path), link)); + } + catch + { + throw new InvalidDataException($"Invalid path specified in '.git' file at '{path}'"); + } + } + + private static bool IsGitDirectory(string directory, 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 + { + // note: git does not trim whitespace + commonDirectory = Path.Combine(directory, File.ReadAllText(commonLinkPath)); + } + 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); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitSubmodule.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitSubmodule.cs new file mode 100644 index 00000000..bd42b467 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitSubmodule.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Build.Tasks.Git +{ + internal readonly struct GitSubmodule + { + public string Name { get; } + + /// + /// Working directory path as specified in .gitmodules file. + /// Expected to be relative to the working directory of the containing repository and have Posix directory separators (not normalized). + /// + public string WorkingDirectoryPath { get; } + + /// + /// An absolute URL or a relative path (if it starts with `./` or `../`) to the default remote of the containing repository. + /// + public string Url { get; } + + public GitSubmodule(string name, string workingDirectoryPath, string url) + { + Name = name; + WorkingDirectoryPath = workingDirectoryPath; + Url = url; + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/Glob.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/Glob.cs new file mode 100644 index 00000000..3bdc2bd5 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/Glob.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// Implementation based on documentation: +// - https://git-scm.com/docs/gitignore +// - http://man7.org/linux/man-pages/man7/glob.7.html +// - https://research.swtch.com/glob + +using System; +using System.Diagnostics; + +namespace Microsoft.Build.Tasks.Git +{ + // https://github.com/dotnet/corefx/issues/18922 + // https://github.com/dotnet/corefx/issues/25873 + + public static class Glob + { + internal static bool IsMatch(string pattern, string path, bool ignoreCase, bool matchWildCardWithDirectorySeparator) + { + int patternIndex = 0; + int pathIndex = 0; + + // true if the next matching character must be the first character of a directory name + bool matchDirectoryNameStart = false; + + bool stopAtPathSlash = false; + + int nextSinglePatternIndex = -1; + int nextSinglePathIndex = -1; + int nextDoublePatternIndex = -1; + int nextDoublePathIndex = -1; + + bool equal(char x, char y) + => x == y || ignoreCase && char.ToLowerInvariant(x) == char.ToLowerInvariant(y); + + while (patternIndex < pattern.Length) + { + var c = pattern[patternIndex++]; + if (c == '*') + { + // "a/**/*" does not match "a", although it might appear from the spec that it should. + + bool isDoubleAsterisk = patternIndex < pattern.Length && pattern[patternIndex] == '*' && + (patternIndex == pattern.Length - 1 || pattern[patternIndex + 1] == '/') && + (patternIndex == 1 || pattern[patternIndex - 2] == '/'); + + if (isDoubleAsterisk) + { + // trailing "/**" + if (patternIndex == pattern.Length - 1) + { + // remaining path definitely matches + return true; + } + + // At this point the initial '/' (if any) is already matched. + Debug.Assert(pattern[patternIndex] == '*' && pattern[patternIndex + 1] == '/'); + + // Continue matching remainder of the pattern following "**/" with the remainder of the path. + // The next path character only matches if it is preceded by '/'. + // Consider the following cases + // "**/a*" ~ "abc" + // "**/b" ~ "x/yb/b" (do not match the first 'b', only the second one) + // "**/?" ~ "x/yz/u" (do not match 'y', 'z'; match 'u') + // "a/**/b*" ~ "a/bcd" + // "a/**/b" ~ "a/x/yb/b" (do not match the first 'b', only the second one) + patternIndex += 2; + + stopAtPathSlash = false; + matchDirectoryNameStart = true; + } + else + { + // trailing "*" + if (patternIndex == pattern.Length) + { + return matchWildCardWithDirectorySeparator || path.IndexOf('/', pathIndex) == -1; + } + + stopAtPathSlash = !matchWildCardWithDirectorySeparator; + matchDirectoryNameStart = false; + } + + // If the rest of the pattern fails to match the rest of the path, we restart matching at the following indices. + // A sequence of consecutive resets is effectively searching the path for a substring that matches the span of the pattern + // in between the current wildcard and the next one. + // + // For example, consider matching pattern "A/**/B/**/C/*.D" to path "A/z/u/B/q/r/C/z.D". + // Processing the first ** wildcard keeps resetting until the pattern is alligned with "/B/" in the path (wildcard matches "z/u"). + // Processing the next ** wildcard keeps resetting until the pattern is alligned with "/C/" in the path (wildcard matches "q/r"). + // Finally, processing the * wildcard aligns on ".D" and the wildcard matches "z". + // + // If ** and * differ in matching '/' (matchWildCardWithDirectorySeparator is false) we need to reset them independently. + // Consider pattern "A/**/B*C*D/**/E" matching to "A/u/v/BaaCaaX/u/BoCoD/u/E". + // If we aligned on substring "/B" in between the first ** and the next * we would not match the path correctly. + // Instead, we need to align on the sub-pattern "/B*C*D/" in between the first and the second **. + if (stopAtPathSlash) + { + nextSinglePatternIndex = patternIndex; + nextSinglePathIndex = pathIndex + 1; + } + else + { + nextDoublePatternIndex = patternIndex; + nextDoublePathIndex = pathIndex + 1; + } + + continue; + } + + bool matching; + + if (c == '?') + { + // "?" matches any character except for "/" (when matchWildCardWithDirectorySeparator is false) + matching = pathIndex < path.Length && (matchWildCardWithDirectorySeparator || path[pathIndex] != '/'); + } + else if (c == '[') + { + // "[]" matches a single character in the range + matching = pathIndex < path.Length && IsRangeMatch(pattern, ref patternIndex, path[pathIndex], ignoreCase, matchWildCardWithDirectorySeparator); + } + else + { + if (c == '\\' && patternIndex < pattern.Length) + { + c = pattern[patternIndex++]; + } + + // match specific character: + matching = pathIndex < path.Length && equal(c, path[pathIndex]); + } + + if (matching && (!matchDirectoryNameStart || pathIndex == 0 || path[pathIndex - 1] == '/')) + { + matchDirectoryNameStart = false; + pathIndex++; + } + else if (nextDoublePatternIndex >= 0 || nextSinglePatternIndex >= 0) + { + // mismatch while matching pattern following a wildcard ** or * + + // "*" matches anything but "/" (when matchWildCardWithDirectorySeparator is false) + if (!stopAtPathSlash || pathIndex < path.Length && path[pathIndex] == '/') + { + // Reset to the last saved ** position, if any. + // Also handles reset of * when matchWildCardWithDirectorySeparator is true. + + if (nextDoublePatternIndex < 0) + { + return false; + } + + patternIndex = nextDoublePatternIndex; + pathIndex = nextDoublePathIndex; + + nextDoublePathIndex++; + } + else + { + // Reset to the last saved * position. + + patternIndex = nextSinglePatternIndex; + pathIndex = nextSinglePathIndex; + + nextSinglePathIndex++; + } + + Debug.Assert(patternIndex >= 0); + Debug.Assert(pathIndex >= 0); + + if (pathIndex >= path.Length) + { + return false; + } + } + else + { + // character mismatch + return false; + } + } + + return pathIndex == path.Length; + } + + private static bool IsRangeMatch(string pattern, ref int patternIndex, char pathChar, bool ignoreCase, bool matchWildCardWithDirectorySeparator) + { + Debug.Assert(pattern[patternIndex - 1] == '['); + + if (patternIndex == pattern.Length) + { + return false; + } + + if (ignoreCase) + { + pathChar = char.ToLowerInvariant(pathChar); + } + + bool negate = false; + bool isEmpty = true; + bool isMatching = false; + + var c = pattern[patternIndex]; + if (c == '!' || c == '^') + { + negate = true; + patternIndex++; + } + + while (patternIndex < pattern.Length) + { + c = pattern[patternIndex++]; + if (c == ']' && !isEmpty) + { + // Range does not match '/', but [^a] matches '/' if matchWildCardWithDirectorySeparator=true. + return (pathChar != '/' || matchWildCardWithDirectorySeparator) && (negate ? !isMatching : isMatching); + } + + if (ignoreCase) + { + c = char.ToLowerInvariant(c); + } + + char d; + + if (patternIndex + 1 < pattern.Length && pattern[patternIndex] == '-' && (d = pattern[patternIndex + 1]) != ']') + { + if (ignoreCase) + { + d = char.ToLowerInvariant(d); + } + + isMatching |= pathChar == c || pathChar > c && pathChar <= d; + patternIndex += 2; + } + else + { + // continue parsing to validate the range is well-formed + isMatching |= pathChar == c; + } + + isEmpty = false; + } + + // malformed range + return false; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs new file mode 100644 index 00000000..b7001f1d --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; + +namespace Microsoft.Build.Tasks.Git +{ + internal static class PathUtils + { + public static bool IsUnixLikePlatform => Path.DirectorySeparatorChar == '/'; + public const char VolumeSeparatorChar = ':'; + public static readonly string DirectorySeparatorStr = Path.DirectorySeparatorChar.ToString(); + private static readonly char[] s_slash = new char[] { '/' }; + private static readonly char[] s_directorySeparators = new char[] { '/' }; + + public static string EnsureTrailingSlash(string path) + => HasTrailingSlash(path) ? path : path + "/"; + + public static string TrimTrailingSlash(string path) + => path.TrimEnd(s_slash); + + public static string TrimTrailingDirectorySeparator(string path) + => path.TrimEnd(s_directorySeparators); + + public static bool HasTrailingSlash(string path) + => path.Length > 0 && path[path.Length - 1] == '/'; + + public static bool HasTrailingDirectorySeparator(string path) + => path.Length > 0 && (path[path.Length - 1] == '/' || path[path.Length - 1] == '\\'); + + public static string ToPosixPath(string path) + => (Path.DirectorySeparatorChar == '\\') ? path.Replace('\\', '/') : path; + + internal static string ToPosixDirectoryPath(string path) + => EnsureTrailingSlash(ToPosixPath(path)); + + internal static bool IsPosixPath(string path) + => Path.DirectorySeparatorChar == '/' || path.IndexOf('\\') < 0; + + public static string CombinePosixPaths(string root, string relativePath) + => CombinePaths(root, relativePath, "/"); + + public static string CombinePaths(string root, string relativePath, string separator) + { + Debug.Assert(!string.IsNullOrEmpty(root)); + + char c = root[root.Length - 1]; + if (!IsDirectorySeparator(c) && c != VolumeSeparatorChar) + { + return root + separator + relativePath; + } + + return root + relativePath; + } + + public static bool IsDirectorySeparator(char c) + => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + /// + /// True if the path is an absolute path (rooted to drive or network share) + /// + public static bool IsAbsolute(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + if (IsUnixLikePlatform) + { + return path[0] == Path.DirectorySeparatorChar; + } + + // "C:\" + if (IsDriveRootedAbsolutePath(path)) + { + // Including invalid paths (e.g. "*:\") + return true; + } + + // "\\machine\share" + // Including invalid/incomplete UNC paths (e.g. "\\goo") + return path.Length >= 2 && + IsDirectorySeparator(path[0]) && + IsDirectorySeparator(path[1]); + } + + /// + /// Returns true if given path is absolute and starts with a drive specification ("C:\"). + /// + private static bool IsDriveRootedAbsolutePath(string path) + { + Debug.Assert(!IsUnixLikePlatform); + return path.Length >= 3 && path[1] == VolumeSeparatorChar && IsDirectorySeparator(path[2]); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.Operations/RepositoryTasks.cs b/src/Microsoft.Build.Tasks.Git.Operations/RepositoryTasks.cs index 79469fd5..5c4068f1 100644 --- a/src/Microsoft.Build.Tasks.Git.Operations/RepositoryTasks.cs +++ b/src/Microsoft.Build.Tasks.Git.Operations/RepositoryTasks.cs @@ -8,7 +8,7 @@ namespace Microsoft.Build.Tasks.Git { internal static class RepositoryTasks { - private static bool Execute(T task, Action action) + private static bool Execute(T task, Action action) where T: RepositoryTask { var log = task.Log; @@ -19,10 +19,10 @@ private static bool Execute(T task, Action action) return true; } - Repository repo; + IRepository repo; try { - repo = new Repository(task.Root); + repo = GitOperations.CreateRepository(task.Root); } catch (RepositoryNotFoundException e) { diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs new file mode 100644 index 00000000..f1fe1e4b --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs @@ -0,0 +1,408 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using TestUtilities; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests +{ + public class GitConfigTests + { + private static IEnumerable Inspect(GitConfig config) + => config.EnumerateVariables().OrderBy(x => x.Key).Select(kvp => $"{kvp.Key}={string.Join("|", kvp.Value)}"); + + private static GitConfig LoadFromString(string gitDirectory, string configPath, string configContent) + => new GitConfig.Reader(gitDirectory, gitDirectory, new GitEnvironment(Path.GetTempPath()), _ => new StringReader(configContent)). + LoadFrom(configPath); + + [Fact] + public void Sections() + { + var config = LoadFromString("/", "/config", $@" +a = 1 ; variable without section +[s1][s2]b = 2 # section without variable followed by another section +[s3]#[s4] + +c = 3 4 "" "" +;xxx +c = "" 5 "" + +d = +#xxx +"); + + AssertEx.Equal(new[] + { + ".a=1", + "s2.b=2", + "s3.c=3 4 | 5 ", + "s3.d=", + }, Inspect(config)); + } + + [Fact] + public void Sections_Errors() + { + Assert.Throws(() => LoadFromString("/", "/config", @" +[s] +a = +1")); + + } + + [Fact] + public void ConditionalInclude() + { + var repoDir = PathUtils.ToPosixPath(Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar)); + var gitDirectory = PathUtils.ToPosixPath(Path.Combine(repoDir, ".git")) + "/"; + + TextReader openFile(string path) + { + Assert.Equal(gitDirectory, PathUtils.EnsureTrailingSlash(PathUtils.ToPosixPath(Path.GetDirectoryName(path)))); + + return new StringReader(Path.GetFileName(path) switch + { + "config" => $@" +[x ""y""] a = 1 +[x ""Y""] a = 2 +[x.Y] A = 3 + +[core] + symlinks = false + ignorecase = true + +[includeIf ""gitdir:{repoDir}""] # not included + path = cfg0 + +[includeIf ""gitdir:{repoDir}/<>""] # not included (does not throw) + path = cfg0 + +[includeIf ""gitdir:{repoDir}\\.git/""] # not included (Windows separator) + path = cfg0 + +[includeIf ""gitdir:{repoDir}/.git/""] # not included (file does not exist) + path = cfg0 + +[includeIf ""gitdir:{repoDir}/.git""] # not included (path doesn't end with slash) + path = cfg0 + +[includeIf ""gitdir:{repoDir}?.git/""] # included + path = cfg2 + +[includeIf ""gitdir:{repoDir}[^a].git/""] # included + path = cfg3 + +[includeIf ""gitdir:{repoDir}/""] # included + path = cfg4 + +[includeIf ""gitdir:{repoDir}/*""] # included + path = cfg5 + +[includeIf ""gitdir:{repoDir}/**""] # included + path = cfg6 + +[includeIf ""gitdir:{repoDir}/**/.git/""] # included + path = cfg7 + +[includeIf ""gitdir/i:{repoDir}/**/.GIT/""] # included + path = cfg8 + +[includeIf ""gitdir/i:~/**/.GIT/""] # included + path = cfg9 + +[includeIf ""gitdir:.""] # not included + path = cfg0 + +[includeIf ""gitdir:./""] # included + path = cfg10 +", + "cfg1" => "[c]n = cfg1", + "cfg2" => "[c]n = cfg2", + "cfg3" => "[c]n = cfg3", + "cfg4" => "[c]n = cfg4", + "cfg5" => "[c]n = cfg5", + "cfg6" => "[c]n = cfg6", + "cfg7" => "[c]n = cfg7", + "cfg8" => "[c]n = cfg8", + "cfg9" => "[c]n = cfg9", + "cfg10" => "[c]n = cfg10", + _ => throw new FileNotFoundException(path) + }); + } + + var config = new GitConfig.Reader(gitDirectory, gitDirectory, new GitEnvironment(repoDir), openFile).LoadFrom(Path.Combine(gitDirectory, "config")); + + AssertEx.Equal(new[] + { + "c.n=cfg2|cfg3|cfg4|cfg5|cfg6|cfg7|cfg8|cfg9|cfg10", + "core.ignorecase=true", + "core.symlinks=false", + $"includeif.gitdir/i:~/**/.GIT/.path=cfg9", + $"includeif.gitdir/i:{repoDir}/**/.GIT/.path=cfg8", + $"includeif.gitdir:..path=cfg0", + $"includeif.gitdir:./.path=cfg10", + $"includeif.gitdir:{repoDir}.path=cfg0", + $"includeif.gitdir:{repoDir}/**.path=cfg6", + $"includeif.gitdir:{repoDir}/**/.git/.path=cfg7", + $"includeif.gitdir:{repoDir}/*.path=cfg5", + $"includeif.gitdir:{repoDir}/.git.path=cfg0", + $"includeif.gitdir:{repoDir}/.git/.path=cfg0", + $"includeif.gitdir:{repoDir}/.path=cfg4", + $"includeif.gitdir:{repoDir}/<>.path=cfg0", + $"includeif.gitdir:{repoDir}?.git/.path=cfg2", + $"includeif.gitdir:{repoDir}[^a].git/.path=cfg3", + $"includeif.gitdir:{repoDir}\\.git/.path=cfg0", + "x.y.a=1|3", + "x.Y.a=2", + }, Inspect(config)); + } + + [Fact] + public void IncludeRecursion() + { + var gitDirectory = PathUtils.ToPosixPath(Path.Combine(Path.GetTempPath(), ".git")) + "/"; + + TextReader openFile(string path) + { + Assert.Equal(gitDirectory, PathUtils.EnsureTrailingSlash(PathUtils.ToPosixPath(Path.GetDirectoryName(path)))); + + return new StringReader(Path.GetFileName(path) switch + { + "config" => @" +[x] + a = 1 + +[include] + path = cfg1 +", + "cfg1" => @" +[x] + a = 2 + +[include] + path = config +", + _ => throw new FileNotFoundException(path) + }); + } + + Assert.Throws(() => new GitConfig.Reader(gitDirectory, gitDirectory, new GitEnvironment("/home"), openFile).LoadFrom(Path.Combine(gitDirectory, "config"))); + } + + [Theory] + [InlineData(true, true, "programdata|sys|xdg|home1|common")] + [InlineData(true, false, "programdata|sys|home2|home1|common")] + [InlineData(false, true, "sys|xdg|home1|common")] + public void HierarchicalLoad(bool enableProgramData, bool enableXdg, string expected) + { + using var temp = new TempRoot(); + var root = temp.CreateDirectory(); + + var gitDir = root.CreateDirectory(".git"); + + var commonDir = root.CreateDirectory("common"); + commonDir.CreateFile("config").WriteAllText("[cfg]dir=common"); + + var homeDir = root.CreateDirectory("home"); + homeDir.CreateFile(".gitconfig").WriteAllText("[cfg]dir=home1"); + homeDir.CreateDirectory(".config").CreateDirectory("git").CreateFile("config").WriteAllText("[cfg]dir=home2"); + + TempDirectory xdgDir = null; + if (enableXdg) + { + xdgDir = root.CreateDirectory("xdg"); + xdgDir.CreateDirectory("git").CreateFile("config").WriteAllText("[cfg]dir=xdg"); + } + + TempDirectory programDataDir = null; + if (enableProgramData) + { + programDataDir = root.CreateDirectory("programdata"); + programDataDir.CreateDirectory("git").CreateFile("config").WriteAllText("[cfg]dir=programdata"); + } + + var systemDir = root.CreateDirectory("sys"); + systemDir.CreateFile("gitconfig").WriteAllText("[cfg]dir=sys"); + + var gitDirectory = PathUtils.EnsureTrailingSlash(PathUtils.ToPosixPath(gitDir.Path)); + var commonDirectory = PathUtils.EnsureTrailingSlash(PathUtils.ToPosixPath(commonDir.Path)); + + var environment = new GitEnvironment( + homeDirectory: homeDir.Path, + xdgConfigHomeDirectory: xdgDir?.Path, + programDataDirectory: programDataDir?.Path, + systemDirectory : systemDir.Path); + + var reader = new GitConfig.Reader(gitDirectory, commonDirectory, environment, File.OpenText); + var gitConfig = reader.Load(); + + AssertEx.Equal(new[] + { + "cfg.dir=" + expected + }, Inspect(gitConfig)); + } + + [Theory] + [InlineData("[X]", "x", "")] + [InlineData("[-]", "-", "")] + [InlineData("[.]", ".", "")] + [InlineData("[..]", "", ".")] + [InlineData("[...]", "", "..")] + [InlineData("[.x]", "", "x")] + [InlineData("[..x]", "", ".x")] + [InlineData("[.X]", "", "x")] + [InlineData("[X.]", "x.", "")] + [InlineData("[X..]", "x", ".")] + [InlineData("[X. \"z\"]", "x.", ".z")] + [InlineData("[X.y]", "x", "y")] + [InlineData("[X.y.z]", "x", "y.z")] + [InlineData("[X-]", "x-", "")] + [InlineData("[-x]", "-x", "")] + [InlineData("[X-y]", "x-y", "")] + [InlineData("[X \"y\"]", "x", "y")] + [InlineData("[X \t\f\v\"y\"]", "x", "y")] + [InlineData("[X.y \"z\"]", "x", "y.z")] + [InlineData("[X.Y \"z\"]", "x", "y.z")] + [InlineData("[X \"/*-\\a\"]", "x", "/*-a")] + public void ReadSectionHeader(string str, string name, string subsectionName) + { + GitConfig.Reader.ReadSectionHeader(new StringReader(str), new StringBuilder(), out var actualName, out var actualSubsectionName); + Assert.Equal(name, actualName); + Assert.Equal(subsectionName, actualSubsectionName); + } + + [Theory] + [InlineData("[")] + [InlineData("[x")] + [InlineData("[x x x]")] + [InlineData("[* \"\\")] + [InlineData("[* \"\\\"]")] + [InlineData("[* \"*\"]")] + [InlineData("[x \"y\" ]")] + public void ReadSectionHeader_Errors(string str) + { + Assert.Throws(() => GitConfig.Reader.ReadSectionHeader(new StringReader(str), new StringBuilder(), out _, out _)); + } + + [Theory] + [InlineData("a", "a", "true")] + [InlineData("A", "a", "true")] + [InlineData("a\r", "a", "true")] + [InlineData("a\r\n", "a", "true")] + [InlineData("a\n", "a", "true")] + [InlineData("a\n\r", "a", "true")] + [InlineData("a \n", "a", "true")] + [InlineData("a# ", "a", "true")] + [InlineData("a;xxx\n", "a", "true")] + [InlineData("a #", "a", "true")] + [InlineData("a=1", "a", "1")] + [InlineData("a-=1", "a-", "1")] + [InlineData("a-4=1", "a-4", "1")] + [InlineData("a-4 =1", "a-4", "1")] + [InlineData("a=1\nb=1", "a", "1")] + [InlineData("a=\"1\\\nb=1\"", "a", "1b=1")] + [InlineData("a=\"1\\nb=1\"", "a", "1\nb=1")] + [InlineData("name=\"a\"x\"b\"", "name", "axb")] + [InlineData("name=\"b\"#\"a\"", "name", "b")] + [InlineData("name=\"b\";\"a\"", "name", "b")] + [InlineData("name=\\\r\nabc", "name", "abc")] + [InlineData("name=\"a\\\n bc\"", "name", "a bc")] + [InlineData("name=a\\\nbc", "name", "abc")] + [InlineData("name=a\\\n bc", "name", "a bc")] + [InlineData("name= 3 4 \" \" ", "name", "3 4 ")] + [InlineData("name= 1\\t", "name", "1\t")] + [InlineData("name= 1\\n", "name", "1\n")] + [InlineData("name= 1\\\\", "name", "1\\")] + [InlineData("name= 1\\\"", "name", "1\"")] + [InlineData("name= ", "name", "")] + [InlineData("name=", "name", "")] + public void ReadVariableDeclaration(string str, string name, string value) + { + GitConfig.Reader.ReadVariableDeclaration(new StringReader(str), new StringBuilder(), out var actualName, out var actualValue); + Assert.Equal(name, actualName); + Assert.Equal(value, actualValue); + } + + [Theory] + [InlineData("")] + [InlineData("*")] + [InlineData("-=1")] + [InlineData("_=1")] + [InlineData("5=1")] + [InlineData("a_=1")] + [InlineData("a*=1")] + [InlineData("name=\\j")] + [InlineData("name=\"")] + [InlineData("name=\"a")] + [InlineData("name=\"a\n")] + [InlineData("name=\"a\nb")] + public void ReadVariableDeclaration_Errors(string str) + { + Assert.Throws(() => GitConfig.Reader.ReadVariableDeclaration(new StringReader(str), new StringBuilder(), out _, out _)); + } + + [Theory] + [InlineData("0", 0)] + [InlineData("10", 10)] + [InlineData("-10", -10)] + [InlineData("10k", 10 * 1024)] + [InlineData("-10K", -10 * 1024)] + [InlineData("10M", 10 * 1024 * 1024)] + [InlineData("-10m", -10 * 1024 * 1024)] + [InlineData("10G", 10L * 1024 * 1024 * 1024)] + [InlineData("-10g", -10L * 1024 * 1024 * 1024)] + [InlineData("-9223372036854775808", long.MinValue)] + [InlineData("9223372036854775807", long.MaxValue)] + public void TryParseInt64Value_Success(string str, long value) + { + Assert.True(GitConfig.TryParseInt64Value(str, out var actualValue)); + Assert.Equal(value, actualValue); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData(" ")] + [InlineData("-")] + [InlineData("k")] + [InlineData("-9223372036854775809")] + [InlineData("9223372036854775808")] + [InlineData("922337203685477580k")] + [InlineData("922337203685477580G")] + public void TryParseInt64Value_Error(string str) + { + Assert.False(GitConfig.TryParseInt64Value(str, out _)); + } + + [Theory] + [InlineData("", false)] + [InlineData("no", false)] + [InlineData("NO", false)] + [InlineData("No", false)] + [InlineData("Off", false)] + [InlineData("0", false)] + [InlineData("False", false)] + [InlineData("1", true)] + [InlineData("tRue", true)] + [InlineData("oN", true)] + [InlineData("yeS", true)] + public void TryParseBooleanValue_Success(string str, bool value) + { + Assert.True(GitConfig.TryParseBooleanValue(str, out var actualValue)); + Assert.Equal(value, actualValue); + } + + [Theory] + [InlineData(null)] + [InlineData("2")] + [InlineData(" ")] + [InlineData("x")] + public void TryParseBooleanValue_Error(string str) + { + Assert.False(GitConfig.TryParseBooleanValue(str, out _)); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs index 013e94aa..0d78504a 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using LibGit2Sharp; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -73,5 +74,16 @@ public void MinimalGitData() MockItem.AdjustSeparators(@"sub\ignore_in_submodule_d"), }, untrackedFiles.Select(item => item.ItemSpec)); } + + [Fact] + public void MinimalGitData2() + { + //Environment.SetEnvironmentVariable("GIT_WORK_TREE", @"D:\temp\x"); + + // ignores GIT_WORK_TREE + // var repository = new Repository(@"D:\git-experiments\r3\main\.git\worktrees\wt1"); + var repository = new Repository(@"D:\git-experiments\r3\main"); + var x = repository.Head.Reference.ResolveToDirectReference()?.TargetIdentifier; + } } } diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs new file mode 100644 index 00000000..142a7011 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.IO; +using System.Linq; +using System.Text; +using TestUtilities; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests +{ + public class GitIgnoreTests + { + [Theory] + [InlineData("\t", "\t", GitIgnore.PatternFlags.None)] + [InlineData("\v", "\v", GitIgnore.PatternFlags.None)] + [InlineData("\f", "\f", GitIgnore.PatternFlags.None)] + [InlineData("\\ ", " ", GitIgnore.PatternFlags.None)] + [InlineData(" #", " #", GitIgnore.PatternFlags.None)] + [InlineData("!x ", "x", GitIgnore.PatternFlags.Negative)] + [InlineData("!x/", "x", GitIgnore.PatternFlags.Negative | GitIgnore.PatternFlags.DirectoryPattern)] + [InlineData("!/x", "x", GitIgnore.PatternFlags.Negative | GitIgnore.PatternFlags.FullPath)] + [InlineData("x/", "x", GitIgnore.PatternFlags.DirectoryPattern)] + [InlineData("/x", "x", GitIgnore.PatternFlags.FullPath)] + [InlineData("//x//", "/x/", GitIgnore.PatternFlags.DirectoryPattern | GitIgnore.PatternFlags.FullPath)] + [InlineData("\\", "\\", GitIgnore.PatternFlags.None)] + [InlineData("\\x", "x", GitIgnore.PatternFlags.None)] + [InlineData("x\\", "x\\", GitIgnore.PatternFlags.None)] + [InlineData("\\\\", "\\", GitIgnore.PatternFlags.None)] + [InlineData("\\abc\\xy\\z", "abcxyz", GitIgnore.PatternFlags.None)] + internal void TryParsePattern(string line, string glob, GitIgnore.PatternFlags flags) + { + Assert.True(GitIgnore.TryParsePattern(line, new StringBuilder(), out var actualGlob, out var actualFlags)); + Assert.Equal(glob, actualGlob); + Assert.Equal(flags, actualFlags); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("#")] + [InlineData("!")] + [InlineData("/")] + [InlineData("//")] + [InlineData("!/")] + [InlineData("!//")] + public void TryParsePattern_None(string line) + { + Assert.False(GitIgnore.TryParsePattern(line, new StringBuilder(), out _, out _)); + } + + [Fact] + public void IsIgnored_CaseSensitive() + { + using var temp = new TempRoot(); + + var rootDir = temp.CreateDirectory(); + var workingDir = rootDir.CreateDirectory("Repo"); + + // root + // A (.gitignore) + // B + // C (.gitignore) + // D1, D2, D3 + var dirA = workingDir.CreateDirectory("A"); + var dirB = dirA.CreateDirectory("B"); + var dirC = dirB.CreateDirectory("C"); + dirC.CreateDirectory("D1"); + dirC.CreateDirectory("D2"); + dirC.CreateDirectory("D3"); + + dirA.CreateFile(".gitignore").WriteAllText(@" +!z.txt +*.txt +!u.txt +!v.txt +!.git +b/ +D3/ +Bar/**/*.xyz +v.txt +"); + dirC.CreateFile(".gitignore").WriteAllText(@" +!a.txt +D2 +D1/c.cs +/*.c +"); + + var ignore = new GitIgnore(root: null, PathUtils.ToPosixDirectoryPath(workingDir.Path), ignoreCase: false); + var matcher = ignore.CreateMatcher(); + + // outside of the working directory: + Assert.Null(matcher.IsPathIgnored(rootDir.Path)); + Assert.Null(matcher.IsPathIgnored(workingDir.Path.ToUpperInvariant())); + + // special case: + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, ".git") + Path.DirectorySeparatorChar)); + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, ".git", "config"))); + + Assert.False(matcher.IsPathIgnored(workingDir.Path)); + Assert.False(matcher.IsPathIgnored(workingDir.Path + Path.DirectorySeparatorChar)); + Assert.False(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "X"))); + + // matches "*.txt" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "b.txt"))); + + // matches "!a.txt" + Assert.False(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "a.txt"))); + + // matches "*.txt", "!z.txt" is ignored + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "z.txt"))); + + // matches "*.txt", overriden by "!u.txt" + Assert.False(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "u.txt"))); + + // matches "*.txt", overriden by "!v.txt", which is overriden by "v.txt" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "v.txt"))); + + // matches directory name "D2" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D2", "E", "a.txt"))); + + // does not match "b/" (treated as a file path) + Assert.False(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "b"))); + + // matches "b/" (treated as a directory path) + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "b") + Path.DirectorySeparatorChar)); + + // matches "D3/" (existing directory path) + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D3"))); + + // matches "D1/c.cs" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "c.cs"))); + + // matches "Bar/**/*.xyz" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "Bar", "Baz", "Goo", ".xyz"))); + + // matches "/*.c" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "x.c"))); + + // does not match "/*.c" + Assert.False(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "x.c"))); + + AssertEx.Equal(new[] + { + "/Repo/.git: True", + "/Repo/A/B/C/D1/b: True", + "/Repo/A/B/C/D1: False", + "/Repo/A/B/C/D2/E: True", + "/Repo/A/B/C/D2: True", + "/Repo/A/B/C/D3: True", + "/Repo/A/B/C: False", + "/Repo/A/B: False", + "/Repo/A: False", + "/Repo: False" + }, matcher.DirectoryIgnoreStateCache.Select(kvp => $"{kvp.Key.Substring(rootDir.Path.Length)}: {kvp.Value}").OrderBy(s => s)); + } + + [Fact] + public void IsIgnored_IgnoreCase() + { + using var temp = new TempRoot(); + + var rootDir = temp.CreateDirectory(); + var workingDir = rootDir.CreateDirectory("Repo"); + + // root + // A (.gitignore) + // diR + var dirA = workingDir.CreateDirectory("A"); + dirA.CreateDirectory("diR"); + + dirA.CreateFile(".gitignore").WriteAllText(@" +*.txt +!a.TXT +dir/ +"); + + var ignore = new GitIgnore(root: null, PathUtils.ToPosixDirectoryPath(workingDir.Path), ignoreCase: true); + var matcher = ignore.CreateMatcher(); + + // outside of the working directory: + Assert.Null(matcher.IsPathIgnored(rootDir.Path.ToUpperInvariant())); + + // special case: + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, ".GIT"))); + + // matches "*.txt" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "b.TXT"))); + + // matches "!a.TXT" + Assert.False(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "a.txt"))); + + // matches directory name "dir/" + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "DIr", "a.txt"))); + + // matches "dir/" (treated as a directory path) + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "DiR") + Path.DirectorySeparatorChar)); + + // matches "dir/" (existing directory path) + Assert.True(matcher.IsPathIgnored(Path.Combine(workingDir.Path, "A", "DIR"))); + + AssertEx.Equal(new[] + { + "/Repo/A/DIr: True", + "/Repo/A: False", + "/Repo: False", + }, matcher.DirectoryIgnoreStateCache.Select(kvp => $"{kvp.Key.Substring(rootDir.Path.Length)}: {kvp.Value}").OrderBy(s => s)); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs new file mode 100644 index 00000000..0236cf2a --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.IO; +using System.Linq; +using TestUtilities; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests +{ + public class GitRepositoryTests + { + [Fact] + public void LocateRepository_Worktree() + { + using var temp = new TempRoot(); + + var mainWorkingDir = temp.CreateDirectory(); + var mainWorkingSubDir = mainWorkingDir.CreateDirectory("A"); + var mainGitDir = mainWorkingDir.CreateDirectory(".git"); + mainGitDir.CreateFile("HEAD"); + + var worktreeGitDir = temp.CreateDirectory(); + var worktreeGitSubDir = worktreeGitDir.CreateDirectory("B"); + var worktreeDir = temp.CreateDirectory(); + var worktreeSubDir = worktreeDir.CreateDirectory("C"); + var worktreeGitFile = worktreeDir.CreateFile(".git").WriteAllText("gitdir: " + worktreeGitDir); + + worktreeGitDir.CreateFile("HEAD"); + worktreeGitDir.CreateFile("commondir").WriteAllText(mainGitDir.Path); + worktreeGitDir.CreateFile("gitdir").WriteAllText(worktreeGitFile.Path); + + // start under main repository directory: + Assert.True(GitRepository.LocateRepository( + mainWorkingSubDir.Path, + out var locatedGitDirectory, + out var locatedCommonDirectory, + out var locatedWorkingDirectory)); + + Assert.Equal(mainGitDir.Path, locatedGitDirectory); + Assert.Equal(mainGitDir.Path, locatedCommonDirectory); + Assert.Equal(mainWorkingDir.Path, locatedWorkingDirectory); + + // start at main git directory (git config works from this dir, but git status requires work dir): + Assert.True(GitRepository.LocateRepository( + mainGitDir.Path, + out locatedGitDirectory, + out locatedCommonDirectory, + out locatedWorkingDirectory)); + + Assert.Equal(mainGitDir.Path, locatedGitDirectory); + Assert.Equal(mainGitDir.Path, locatedCommonDirectory); + Assert.Null(locatedWorkingDirectory); + + // start under worktree directory: + Assert.True(GitRepository.LocateRepository( + worktreeSubDir.Path, + out locatedGitDirectory, + out locatedCommonDirectory, + out locatedWorkingDirectory)); + + Assert.Equal(worktreeGitDir.Path, locatedGitDirectory); + Assert.Equal(mainGitDir.Path, locatedCommonDirectory); + Assert.Equal(worktreeDir.Path, locatedWorkingDirectory); + + // start under worktree git directory (git config works from this dir, but git status requires work dir): + Assert.True(GitRepository.LocateRepository( + worktreeGitSubDir.Path, + out locatedGitDirectory, + out locatedCommonDirectory, + out locatedWorkingDirectory)); + + Assert.Equal(worktreeGitDir.Path, locatedGitDirectory); + Assert.Equal(mainGitDir.Path, locatedCommonDirectory); + Assert.Null(locatedWorkingDirectory); + } + + [Fact] + public void LocateRepository_Submodule() + { + using var temp = new TempRoot(); + + var mainWorkingDir = temp.CreateDirectory(); + var mainGitDir = mainWorkingDir.CreateDirectory(".git"); + mainGitDir.CreateFile("HEAD"); + + var submoduleGitDir = mainGitDir.CreateDirectory("modules").CreateDirectory("sub"); + + var submoduleWorkDir = temp.CreateDirectory(); + submoduleWorkDir.CreateFile(".git").WriteAllText("gitdir: " + submoduleGitDir.Path); + + submoduleGitDir.CreateFile("HEAD"); + submoduleGitDir.CreateDirectory("objects"); + submoduleGitDir.CreateDirectory("refs"); + + // start under submodule working directory: + Assert.True(GitRepository.LocateRepository( + submoduleWorkDir.Path, + out var locatedGitDirectory, + out var locatedCommonDirectory, + out var locatedWorkingDirectory)); + + Assert.Equal(submoduleGitDir.Path, locatedGitDirectory); + Assert.Equal(submoduleGitDir.Path, locatedCommonDirectory); + Assert.Equal(submoduleWorkDir.Path, locatedWorkingDirectory); + + // start under submodule git directory: + Assert.True(GitRepository.LocateRepository( + submoduleGitDir.Path, + out locatedGitDirectory, + out locatedCommonDirectory, + out locatedWorkingDirectory)); + + Assert.Equal(submoduleGitDir.Path, locatedGitDirectory); + Assert.Equal(submoduleGitDir.Path, locatedCommonDirectory); + Assert.Null(locatedWorkingDirectory); + } + + [Fact] + public void OpenRepository() + { + using var temp = new TempRoot(); + + var homeDir = temp.CreateDirectory(); + + var workingDir = temp.CreateDirectory(); + var gitDir = workingDir.CreateDirectory(".git"); + + gitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/master"); + gitDir.CreateDirectory("refs").CreateDirectory("heads").CreateFile("master").WriteAllText("0000000000000000000000000000000000000000"); + gitDir.CreateDirectory("objects"); + + gitDir.CreateFile("config").WriteAllText("[x]a = 1"); + + var src = workingDir.CreateDirectory("src"); + + var repository = GitRepository.OpenRepository(src.Path, new GitEnvironment(homeDir.Path)); + + Assert.Equal(gitDir.Path, repository.CommonDirectory); + Assert.Equal(gitDir.Path, repository.GitDirectory); + Assert.Equal("1", repository.Config.GetVariableValue("x", "a")); + Assert.Empty(repository.GetSubmodules()); + Assert.Equal("0000000000000000000000000000000000000000", repository.GetHeadCommitSha()); + } + + [Fact] + public void OpenReopsitory_VersionNotSupported() + { + using var temp = new TempRoot(); + + var homeDir = temp.CreateDirectory(); + + var workingDir = temp.CreateDirectory(); + var gitDir = workingDir.CreateDirectory(".git"); + + gitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/master"); + gitDir.CreateDirectory("refs").CreateDirectory("heads").CreateFile("master").WriteAllText("0000000000000000000000000000000000000000"); + gitDir.CreateDirectory("objects"); + + gitDir.CreateFile("config").WriteAllText("[core]repositoryformatversion = 1"); + + var src = workingDir.CreateDirectory("src"); + + Assert.Throws(() => GitRepository.OpenRepository(src.Path, new GitEnvironment(homeDir.Path))); + } + + [Fact] + public void Submodules() + { + using var temp = new TempRoot(); + + var workingDir = temp.CreateDirectory(); + var gitDir = workingDir.CreateDirectory(".git"); + workingDir.CreateFile(".gitmodules").WriteAllText(@" +[submodule ""S1""] + path = subs/s1 + url = http://github.com/test1 +[submodule ""S2""] + path = s2 + url = http://github.com/test2 +[submodule ""S3""] + path = s3 + url = ../repo2 +[abc ""S3""] # ignore other sections + path = s3 + url = ../repo2 +[submodule ""S2""] # use the latest + url = http://github.com/test3 +[submodule ""S4""] # ignore if path unspecified + url = http://github.com/test3 +[submodule ""S5""] # ignore if url unspecified + path = s4 +"); + + var repository = new GitRepository(new GitEnvironment("/home"), GitConfig.Empty, gitDir.Path, gitDir.Path, workingDir.Path); + + var submodules = repository.GetSubmodules(); + AssertEx.Equal(new[] + { + "S1: 'subs/s1' 'http://github.com/test1'", + "S2: 's2' 'http://github.com/test3'", + "S3: 's3' '../repo2'", + }, submodules.Select(s => $"{s.Name}: '{s.WorkingDirectoryPath}' '{s.Url}'")); + } + + [Fact] + public void ResolveReference() + { + using var temp = new TempRoot(); + + var commonDir = temp.CreateDirectory(); + var refsHeadsDir = commonDir.CreateDirectory("refs").CreateDirectory("heads"); + + refsHeadsDir.CreateFile("master").WriteAllText("0000000000000000000000000000000000000000"); + refsHeadsDir.CreateFile("br1").WriteAllText("ref: refs/heads/br2"); + refsHeadsDir.CreateFile("br2").WriteAllText("ref: refs/heads/master"); + + Assert.Equal("0123456789ABCDEFabcdef000000000000000000", GitRepository.ResolveReference("0123456789ABCDEFabcdef000000000000000000", commonDir.Path)); + + Assert.Equal("0000000000000000000000000000000000000000", GitRepository.ResolveReference("ref: refs/heads/master", commonDir.Path)); + Assert.Equal("0000000000000000000000000000000000000000", GitRepository.ResolveReference("ref: refs/heads/br1", commonDir.Path)); + Assert.Equal("0000000000000000000000000000000000000000", GitRepository.ResolveReference("ref: refs/heads/br2", commonDir.Path)); + + Assert.Null(GitRepository.ResolveReference("ref: refs/heads/none", commonDir.Path)); + } + + [Fact] + public void ResolveReference_Errors() + { + using var temp = new TempRoot(); + + var commonDir = temp.CreateDirectory(); + var refsHeadsDir = commonDir.CreateDirectory("refs").CreateDirectory("heads"); + + refsHeadsDir.CreateFile("rec1").WriteAllText("ref: refs/heads/rec2"); + refsHeadsDir.CreateFile("rec2").WriteAllText("ref: refs/heads/rec1"); + + Assert.Throws(() => GitRepository.ResolveReference("ref: refs/heads/rec1", commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference("ref: xyz/heads/rec1", commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference("ref: refs/heads/<>", commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference("ref:refs/heads/rec1", commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference("ref: refs/heads/rec1 ", commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference("refs/heads/rec1", commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference(new string('0', 39), commonDir.Path)); + Assert.Throws(() => GitRepository.ResolveReference(new string('0', 41), commonDir.Path)); + } + + [Fact] + public void GetHeadCommitSha() + { + using var temp = new TempRoot(); + + var commonDir = temp.CreateDirectory(); + var refsHeadsDir = commonDir.CreateDirectory("refs").CreateDirectory("heads"); + refsHeadsDir.CreateFile("master").WriteAllText("0000000000000000000000000000000000000000"); + + var gitDir = temp.CreateDirectory(); + gitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/master"); + + var repository = new GitRepository(new GitEnvironment("/home"), GitConfig.Empty, gitDir.Path, commonDir.Path, workingDirectory: null); + Assert.Equal("0000000000000000000000000000000000000000", repository.GetHeadCommitSha()); + } + + [Fact] + public void GetSubmoduleHeadCommitSha() + { + using var temp = new TempRoot(); + + var gitDir = temp.CreateDirectory(); + var workingDir = temp.CreateDirectory(); + + var submoduleGitDir = temp.CreateDirectory(); + + var submoduleWorkingDir = workingDir.CreateDirectory("sub").CreateDirectory("abc"); + submoduleWorkingDir.CreateFile(".git").WriteAllText("gitdir: " + submoduleGitDir.Path); + + var submoduleRefsHeadsDir = submoduleGitDir.CreateDirectory("refs").CreateDirectory("heads"); + submoduleRefsHeadsDir.CreateFile("master").WriteAllText("0000000000000000000000000000000000000000"); + submoduleGitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/master"); + + var repository = new GitRepository(new GitEnvironment("/home"), GitConfig.Empty, gitDir.Path, gitDir.Path, workingDir.Path); + Assert.Equal("0000000000000000000000000000000000000000", repository.GetSubmoduleHeadCommitSha(submoduleWorkingDir.Path)); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GlobTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GlobTests.cs new file mode 100644 index 00000000..f519c24c --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GlobTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests +{ + public class GlobTests + { + [Theory] + [InlineData("?", "?")] + [InlineData("*", "")] + [InlineData("*", "a")] + [InlineData("*", "abc")] + [InlineData("a*", "abc")] + [InlineData("a**", "abc")] + [InlineData("a*************************", "abc")] + [InlineData(".", ".")] + [InlineData("./", "./")] + [InlineData(".x", ".x")] + [InlineData("?x", ".x")] + [InlineData("*x", ".x")] + [InlineData("a/**", "a/")] + [InlineData("a/**", "a/b")] + [InlineData("**/b", "b")] + [InlineData("**/a*", "abc")] + [InlineData("**/b", "a/b")] + [InlineData("a/**/b", "a/b")] + [InlineData("a/**/b", "a/x/yb/b")] + [InlineData("A/**/B/**/C/*.D", "A/z/u/B/q/r/C/z.D")] + [InlineData("A/**/B*C*D/**/E", "A/u/v/BaaCaaX/u/BoCoD/u/E")] + [InlineData("a/**/*.x", "a/b/c/d.x")] + [InlineData("a*b*c*d", "axxbyyczzd")] + [InlineData("a*?b", "abb")] + [InlineData("a*bcd", "axbbcybcd")] + [InlineData("a*bcd*", "axbbcdybcd")] + [InlineData("*/b", "/b")] + [InlineData(@"\", @"\")] + [InlineData(@"\/", @"/")] + [InlineData(@"\t", @"t")] + [InlineData(@"\?", @"?")] + [InlineData(@"\\", @"\")] + [InlineData("*", @"\")] + [InlineData("?", @"\")] + [InlineData("[a-]]", "a]")] + [InlineData("[a-]]", "-]")] + public void Matching(string pattern, string path) + { + Assert.True(Glob.IsMatch(pattern, path, ignoreCase: false, matchWildCardWithDirectorySeparator: true)); + Assert.True(Glob.IsMatch(pattern, path, ignoreCase: false, matchWildCardWithDirectorySeparator: false)); + } + + [Theory] + [InlineData("?", "/")] + [InlineData("*", "/")] + [InlineData("*", "a/")] + [InlineData("[--0]", "/")] + [InlineData("[/]", "/")] + [InlineData("a*?b", "a/b")] + [InlineData("a*?b", "ab/b")] + public void Matching_WildCardMatchesDirectorySeparator(string pattern, string path) + { + Assert.True(Glob.IsMatch(pattern, path, ignoreCase: false, matchWildCardWithDirectorySeparator: true)); + } + + [Theory] + [InlineData("?", "")] + [InlineData("?", "/")] + [InlineData("*", "/")] + [InlineData("*.txt", "")] + [InlineData("a/**", "a")] + [InlineData("a/**/*", "a")] + [InlineData("*", "a/")] + [InlineData("a*b*c*d", "axxbyyczz")] + [InlineData("a*d", "abc/de")] + [InlineData("***/b", "b")] + [InlineData("a*?b", "a/b")] + [InlineData("a*?b", "ab/b")] + [InlineData("a*bcd", "axbbcybcdz")] + [InlineData("a*bcd", "axbbcdybcd")] + [InlineData("[/]", "/")] + [InlineData("[--0]", "/")] + [InlineData("[", "[")] + [InlineData("[!", "[!")] + [InlineData("[a", "[a")] + [InlineData("[a-", "[a-")] + [InlineData("[a-]]", "]]")] + public void NonMatching(string pattern, string path) + { + Assert.False(Glob.IsMatch(pattern, path, ignoreCase: false, matchWildCardWithDirectorySeparator: false)); + } + + [Theory] + [InlineData("[][!]", new[] { '[', ']', '!' })] + [InlineData("[A-Ca-b0-1]", new[] { 'A', 'B', 'C', 'a', 'b', '0', '1' })] + [InlineData("[--0]", new[] { '-', '.', '0' }, new[] { '-', '.', '0', '/' })] // range contains '-', '.', '/', '0', but '/' should not match + [InlineData("[]-]", new[] { ']', '-' })] + [InlineData("[a-]", new[] { 'a', '-' })] + [InlineData(@"[\]", new[] { '\\' })] + [InlineData(@"[[?*\]", new[] { '[', '?', '*', '\\' })] + [InlineData("[b-a]", new[] { 'b' })] + [InlineData("[!]", new char[0])] + [InlineData("[^]", new char[0])] + [InlineData("[]", new char[0])] + [InlineData("[a-]]", new char[0])] + public void MatchingRange(string pattern, char[] matchingChars, char[] matchingCharsWildCardMatchesSeparator = null) + { + for (int i = 0; i < 255; i++) + { + var c = (char)i; + bool shouldMatch = Array.IndexOf(matchingChars, c) >= 0; + + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: false, matchWildCardWithDirectorySeparator: false), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + + if (matchingCharsWildCardMatchesSeparator != null) + { + shouldMatch = Array.IndexOf(matchingCharsWildCardMatchesSeparator, c) >= 0; + } + + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: false, matchWildCardWithDirectorySeparator: true), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + } + } + + [Theory] + [InlineData("[^/]", new[] { '/' })] + [InlineData("[^--0]", new[] { '-', '.', '/', '0' })] // range contains '-', '.', '/', '0' + public void NonMatchingRange(string pattern, char[] nonMatchingChars) + { + for (int i = 0; i < 255; i++) + { + var c = (char)i; + bool shouldMatch = Array.IndexOf(nonMatchingChars, c) < 0; + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: false, matchWildCardWithDirectorySeparator: false), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: false, matchWildCardWithDirectorySeparator: true), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + } + } + + [Theory] + [InlineData("[!]a-]", new[] { ']', 'a', '-', '/' })] + public void NonMatchingRange_WildCardDoesNotMatchDirectorySeparator(string pattern, char[] nonMatchingChars) + { + for (int i = 0; i < 255; i++) + { + var c = (char)i; + bool shouldMatch = Array.IndexOf(nonMatchingChars, c) < 0; + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: false, matchWildCardWithDirectorySeparator: false), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + } + } + + [Theory] + [InlineData("[!]a-]", new[] { ']', 'a', '-' })] + public void NonMatchingRange_WildCardMatchesDirectorySeparator(string pattern, char[] nonMatchingChars) + { + for (int i = 0; i < 255; i++) + { + var c = (char)i; + bool shouldMatch = Array.IndexOf(nonMatchingChars, c) < 0; + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: false, matchWildCardWithDirectorySeparator: true), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + } + } + + [Theory] + [InlineData("[a-b0-1]", new[] { 'A', 'B', 'a', 'b', '0', '1' })] + [InlineData("[a-]", new[] { 'a', 'A', '-' })] + [InlineData("[b-a]", new[] { 'b', 'B' })] + public void MatchingRangeIgnoreCase(string pattern, char[] matchingChars) + { + for (int i = 0; i < 255; i++) + { + var c = (char)i; + bool shouldMatch = Array.IndexOf(matchingChars, c) >= 0; + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: true, matchWildCardWithDirectorySeparator: false), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + + Assert.True(shouldMatch == Glob.IsMatch(pattern, c.ToString(), ignoreCase: true, matchWildCardWithDirectorySeparator: true), + $"character: '{(i != 0 ? c.ToString() : "\\0")}' (0x{i:X2})"); + } + } + } +}