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