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