diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs
index 97cc7d31..a94db190 100644
--- a/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs
+++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs
@@ -50,6 +50,9 @@ public Pattern(string glob, PatternFlags 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]
@@ -67,6 +70,7 @@ internal enum PatternFlags
/// Full posix slash terminated path.
///
private readonly string _workingDirectory;
+ private readonly string _workingDirectoryNoSlash;
private readonly bool _ignoreCase;
@@ -84,17 +88,39 @@ internal GitIgnore(PatternGroup root, string workingDirectory, bool ignoreCase)
_ignoreCase = ignoreCase;
_workingDirectory = workingDirectory;
+ _workingDirectoryNoSlash = PathUtils.TrimTrailingSlash(workingDirectory);
_root = root;
_patternGroups = new Dictionary();
}
- // TODO: OS path comparison?
private StringComparison PathComparison
=> _ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
- private bool IsDefaultPattern(string pattern)
- => pattern == "." || pattern == ".." || pattern.Equals(".git", PathComparison);
+ ///
+ /// 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(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))
@@ -102,37 +128,79 @@ private bool IsDefaultPattern(string pattern)
throw new ArgumentException("Path must be absolute", nameof(fullPath));
}
- string directory = PathUtils.ToPosixDirectoryPath(Path.GetDirectoryName(fullPath));
+ var fullPathNoSlash = PathUtils.TrimTrailingDirectorySeparator(Path.GetFullPath(fullPath));
+
+ // git uses the FS case-sensitivity for checking directory existence:
+ bool isDirectoryPath = PathUtils.HasTrailingDirectorySeparator(fullPath) || Directory.Exists(fullPath);
+
+ if (isDirectoryPath && fullPathNoSlash.Equals(_workingDirectoryNoSlash, PathComparison))
+ {
+ return false;
+ }
+
+ return IsPathIgnored(fullPathNoSlash, isDirectoryPath);
+ }
+
+ private bool? IsPathIgnored(string normalizedPath, bool isDirectoryPath)
+ {
+ Debug.Assert(PathUtils.IsAbsolute(normalizedPath));
+ Debug.Assert(!PathUtils.HasTrailingDirectorySeparator(normalizedPath));
+
+ normalizedPath = PathUtils.ToPosixPath(normalizedPath);
// paths outside of working directory:
- if (!directory.StartsWith(_workingDirectory, PathComparison))
+ if (!normalizedPath.StartsWith(_workingDirectory, PathComparison))
{
return null;
}
- fullPath = PathUtils.ToPosixPath(fullPath);
- bool isDirectoryPath = PathUtils.HasTrailingSlash(fullPath) || Directory.Exists(fullPath);
- fullPath = PathUtils.TrimTrailingSlash(fullPath);
-
+ static void splitPath(string fullPath, out string directoryWithSlash, out string fileName)
+ {
+ int i = fullPath.LastIndexOf('/', fullPath.Length - (PathUtils.HasTrailingSlash(fullPath) ? 2 : 1));
+ if (i < 0)
+ {
+ directoryWithSlash = null;
+ fileName = fullPath;
+ }
+ else
+ {
+ directoryWithSlash = fullPath.Substring(0, i + 1);
+ fileName = fullPath.Substring(i + 1);
+ }
+ }
+
+ splitPath(normalizedPath, out var directory, out var fileName);
+ Debug.Assert(directory != null);
+
// Default patterns can't be overriden by a negative pattern:
- string fileName = Path.GetFileName(fullPath);
- if (IsDefaultPattern(fileName))
+ if (fileName.Equals(".git", PathComparison))
{
return true;
}
+ var groups = new List();
+
while (true)
{
bool isIgnored = false;
+ // Visit groups in reverse order.
+ // Patterns specified closer to the target file override those specified above.
for (var patternGroup = GetPatternGroup(directory); patternGroup != null; patternGroup = patternGroup.Parent)
{
- if (!fullPath.StartsWith(patternGroup.ContainingDirectory, PathComparison))
+ groups.Add(patternGroup);
+ }
+
+ for (int i = groups.Count - 1; i >= 0; i--)
+ {
+ var patternGroup = groups[i];
+
+ if (!normalizedPath.StartsWith(patternGroup.ContainingDirectory, PathComparison))
{
continue;
}
- var relativePath = fullPath.Substring(patternGroup.ContainingDirectory.Length);
+ var relativePath = normalizedPath.Substring(patternGroup.ContainingDirectory.Length);
foreach (var pattern in patternGroup.Patterns)
{
@@ -162,13 +230,14 @@ private bool IsDefaultPattern(string pattern)
return true;
}
- fullPath = PathUtils.ToPosixPath(Path.GetDirectoryName(fullPath));
- fileName = Path.GetFileName(fullPath);
- directory = PathUtils.ToPosixDirectoryPath(Path.GetDirectoryName(fullPath));
- if (!directory.StartsWith(_workingDirectory, PathComparison))
+ splitPath(directory, out directory, out fileName);
+ if (directory == null || !directory.StartsWith(_workingDirectory, PathComparison))
{
return false;
}
+
+ isDirectoryPath = true;
+ groups.Clear();
}
}
diff --git a/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs b/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs
index 58946d0c..b7001f1d 100644
--- a/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs
+++ b/src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs
@@ -12,6 +12,7 @@ internal static class PathUtils
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 + "/";
@@ -19,9 +20,15 @@ public static string EnsureTrailingSlash(string 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;
diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs
index 8a6ff2d5..5715b32e 100644
--- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs
+++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs
@@ -48,7 +48,7 @@ public void TryParsePattern_None(string line)
}
[Fact]
- public void IsIgnored()
+ public void IsIgnored_CaseSensitive()
{
using var temp = new TempRoot();
@@ -68,36 +68,51 @@ public void IsIgnored()
dirC.CreateDirectory("D3");
dirA.CreateFile(".gitignore").WriteAllText(@"
-!a.txt
-!.
-!..
+!z.txt
+*.txt
+!u.txt
+!v.txt
!.git
b/
D3/
Bar/**/*.xyz
+v.txt
");
dirC.CreateFile(".gitignore").WriteAllText(@"
-*.txt
+!a.txt
D2
D1/c.cs
+/*.c
");
var ignore = new GitIgnore(root: null, PathUtils.ToPosixDirectoryPath(workingDir.Path), ignoreCase: false);
// outside of the working directory:
Assert.Null(ignore.IsPathIgnored(root.Path));
-
- // default patterns:
- Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, ".")));
- Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "..")));
+ Assert.Null(ignore.IsPathIgnored(workingDir.Path.ToUpperInvariant()));
+
+ // special case:
Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, ".git")));
+ Assert.False(ignore.IsPathIgnored(workingDir.Path));
+ Assert.False(ignore.IsPathIgnored(workingDir.Path + Path.DirectorySeparatorChar));
+ Assert.False(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "X")));
+
// matches "*.txt"
Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "b.txt")));
// matches "!a.txt"
Assert.False(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "a.txt")));
+ // matches "*.txt", "!z.txt" is ignored
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "z.txt")));
+
+ // matches "*.txt", overriden by "!u.txt"
+ Assert.False(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "u.txt")));
+
+ // matches "*.txt", overriden by "!v.txt", which is overriden by "v.txt"
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "v.txt")));
+
// matches directory name "D2"
Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D2", "E", "a.txt")));
@@ -115,6 +130,56 @@ public void IsIgnored()
// matches "Bar/**/*.xyz"
Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "Bar", "Baz", "Goo", ".xyz")));
+
+ // matches "/*.c"
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "x.c")));
+
+ // does not match "/*.c"
+ Assert.False(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "B", "C", "D1", "x.c")));
+ }
+
+ [Fact]
+ public void IsIgnored_IgnoreCase()
+ {
+ using var temp = new TempRoot();
+
+ var root = temp.CreateDirectory();
+ var workingDir = root.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);
+
+ // outside of the working directory:
+ Assert.Null(ignore.IsPathIgnored(root.Path.ToUpperInvariant()));
+
+ // special case:
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, ".GIT")));
+
+ // matches "*.txt"
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "b.TXT")));
+
+ // matches "!a.TXT"
+ Assert.False(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "a.txt")));
+
+ // matches directory name "dir/"
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "DIr", "a.txt")));
+
+ // matches "dir/" (treated as a directory path)
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "DiR") + Path.DirectorySeparatorChar));
+
+ // matches "dir/" (existing directory path)
+ Assert.True(ignore.IsPathIgnored(Path.Combine(workingDir.Path, "A", "DIR")));
}
}
}