Skip to content

Commit

Permalink
Fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
tmat committed Jun 19, 2019
1 parent ff6678a commit 111d9af
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 26 deletions.
103 changes: 86 additions & 17 deletions src/Microsoft.Build.Tasks.Git.Operations/Managed/GitIgnore.cs
Expand Up @@ -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 ? " <dir>" : "")}{(IsFullPathPattern ? " <path>" : "")}";
}

[Flags]
Expand All @@ -67,6 +70,7 @@ internal enum PatternFlags
/// Full posix slash terminated path.
/// </summary>
private readonly string _workingDirectory;
private readonly string _workingDirectoryNoSlash;

private readonly bool _ignoreCase;

Expand All @@ -84,55 +88,119 @@ internal GitIgnore(PatternGroup root, string workingDirectory, bool ignoreCase)

_ignoreCase = ignoreCase;
_workingDirectory = workingDirectory;
_workingDirectoryNoSlash = PathUtils.TrimTrailingSlash(workingDirectory);
_root = root;
_patternGroups = new Dictionary<string, PatternGroup>();
}

// TODO: OS path comparison?
private StringComparison PathComparison
=> _ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

private bool IsDefaultPattern(string pattern)
=> pattern == "." || pattern == ".." || pattern.Equals(".git", PathComparison);
/// <summary>
/// Checks if the specified file path is ignored.
/// </summary>
/// <param name="fullPath">Normalized path.</param>
/// <returns>True if the path is ignored, fale if it is not, null if it is outside of the working directory.</returns>
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);
}

/// <summary>
/// Checks if the specified path is ignored.
/// </summary>
/// <param name="fullPath">Full path.</param>
/// <returns>True if the path is ignored, fale if it is not, null if it is outside of the working directory.</returns>
public bool? IsPathIgnored(string fullPath)
{
if (!PathUtils.IsAbsolute(fullPath))
{
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<PatternGroup>();

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)
{
Expand Down Expand Up @@ -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();
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/Microsoft.Build.Tasks.Git.Operations/Managed/PathUtils.cs
Expand Up @@ -12,16 +12,23 @@ 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 + "/";

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;

Expand Down
83 changes: 74 additions & 9 deletions src/Microsoft.Build.Tasks.Git.UnitTests/GitIgnoreTests.cs
Expand Up @@ -48,7 +48,7 @@ public void TryParsePattern_None(string line)
}

[Fact]
public void IsIgnored()
public void IsIgnored_CaseSensitive()
{
using var temp = new TempRoot();

Expand All @@ -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")));

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

0 comments on commit 111d9af

Please sign in to comment.