Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<PackageVersion Include="DiffEngine" Version="7.3.0" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="GitignoreParserNet" Version="0.2.0.14" />
<PackageVersion Include="ini-parser-netstandard" Version="2.5.3" />
<PackageVersion Include="LibGit2Sharp" Version="0.31.0" />
<PackageVersion Include="LovettSoftware.XmlDiff" Version="1.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="9.0.10" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
Expand All @@ -28,7 +28,7 @@
<PackageVersion Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageVersion Include="StrongNamer" Version="0.2.5" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.IO.Abstractions" Version="22.0.16" />
<PackageVersion Include="System.IO.Abstractions" Version="22.1.0" />
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.16" />
<PackageVersion Include="System.IO.Hashing" Version="9.0.10" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
Expand Down
1 change: 0 additions & 1 deletion Src/CSharpier.Cli/CSharpier.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
<PublicKey>002400000480000094000000060200000024000052534131000400000100010049d266ea1aeae09c0abfce28b8728314d4e4807126ee8bc56155a7ddc765997ed3522908b469ae133fc49ef0bfa957df36082c1c2e0ec8cdc05a4ca4dbd4e1bea6c17fc1008555e15af13a8fc871a04ffc38f5e60e6203bfaf01d16a2a283b90572ade79135801c1675bf38b7a5a60ec8353069796eb53a26ffdddc9ee1273be</PublicKey>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitignoreParserNet" />
<PackageReference Include="ini-parser-netstandard" />
<PackageReference Include="NReco.Logging.File" />
<PackageReference Include="StrongNamer" />
Expand Down
130 changes: 130 additions & 0 deletions Src/CSharpier.Cli/DotIgnore/IgnoreList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// based on the code at https://github.com/markashleybell/MAB.DotIgnore
// simplified to remove unneeded features and fixed a couple of edgecases that were not handled correctly

using System.Collections.Concurrent;
using System.IO.Abstractions;

namespace CSharpier.Cli.DotIgnore;

internal class IgnoreList(string basePath)
{
private static readonly string[] alwaysIgnoredText =
[
"**/bin",
"**/node_modules",
"**/obj",
"**/.git",
];
private readonly List<IgnoreRule> rules = [];

public static async Task<IgnoreList> CreateAsync(
IFileSystem fileSystem,
string basePath,
string? ignoreFilePath,
CancellationToken cancellationToken
)
{
var ignoreList = new IgnoreList(basePath);
ignoreList.AddRules(
alwaysIgnoredText.Concat(
ignoreFilePath is null
? Enumerable.Empty<string>()
: await fileSystem.File.ReadAllLinesAsync(ignoreFilePath, cancellationToken)
)
);
return ignoreList;
}

private void AddRules(IEnumerable<string> newRules)
{
this.rules.AddRange(
newRules
.Select(o => o.Trim())
.Where(o => o.Length > 0 && !o.StartsWith('#'))
.Select(o => new IgnoreRule(o))
);
}

public (bool hasMatchingRule, bool isIgnored) IsIgnored(string path)
{
if (!path.StartsWith(basePath, StringComparison.Ordinal))
{
return (false, false);
}

var pathRelativeToIgnoreFile =
path.Length > basePath.Length
? path[basePath.Length..].Replace('\\', '/')
: string.Empty;

var ancestorIgnored = this.IsAnyParentDirectoryIgnored(pathRelativeToIgnoreFile);

if (ancestorIgnored)
{
return (true, true);
}

return this.IsPathIgnored(pathRelativeToIgnoreFile, false);
}

private bool IsAnyParentDirectoryIgnored(string path)
{
var nextPathIndex = path.LastIndexOf('/');
if (nextPathIndex > 0)
{
return this.IsDirectoryIgnored(path[..nextPathIndex]);
}

return false;
}

private readonly ConcurrentDictionary<string, bool> directoryIgnoredByPath = new();

private bool IsDirectoryIgnored(string path)
{
if (this.directoryIgnoredByPath.TryGetValue(path, out var isIgnored))
{
return isIgnored;
}

if (this.IsPathIgnored(path, true) is (true, true))
{
isIgnored = true;
}

if (!isIgnored)
{
var nextPathIndex = path.LastIndexOf('/');
if (nextPathIndex > 0)
{
isIgnored = this.IsDirectoryIgnored(path[..nextPathIndex]);
}
}

this.directoryIgnoredByPath.TryAdd(path, isIgnored);
return isIgnored;
}

private (bool hasMatchingRule, bool isIgnored) IsPathIgnored(string path, bool pathIsDirectory)
{
// This pattern modified from https://github.com/henon/GitSharp/blob/master/GitSharp/IgnoreRules.cs
var isIgnored = false;
var hasMatchingRule = false;

foreach (var rule in this.rules)
{
var isNegativeRule = (rule.PatternFlags & PatternFlags.NEGATION) != 0;

if (
(!isIgnored && isNegativeRule || isIgnored == isNegativeRule)
&& rule.IsMatch(path, pathIsDirectory)
)
{
hasMatchingRule = true;
isIgnored = !isNegativeRule;
}
}

return (hasMatchingRule, isIgnored);
}
}
116 changes: 116 additions & 0 deletions Src/CSharpier.Cli/DotIgnore/IgnoreRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Text.RegularExpressions;

namespace CSharpier.Cli.DotIgnore;

internal class IgnoreRule
{
private static readonly char[] _wildcardChars = ['*', '[', '?'];

private readonly int wildcardIndex;
private readonly Regex? regex;
private readonly StringComparison stringComparison = StringComparison.Ordinal;

public string Pattern { get; }
public PatternFlags PatternFlags { get; }

public IgnoreRule(string pattern)
{
this.Pattern = pattern;

this.PatternFlags = PatternFlags.NONE;

// If the pattern starts with an exclamation mark, it's a negation pattern
// Once we know that, we can remove the exclamation mark (so the pattern behaves
// just like any other), then just negate the match result when we return it
if (this.Pattern.StartsWith('!'))
{
this.PatternFlags |= PatternFlags.NEGATION;
this.Pattern = this.Pattern[1..];
}

// If the pattern starts with a forward slash, it should only match an absolute path
if (this.Pattern.StartsWith('/'))
{
this.PatternFlags |= PatternFlags.ABSOLUTE_PATH;
this.Pattern = this.Pattern[1..];
}

// If the pattern ends with a forward slash, it should only match a directory
// Again though, once we know that we can remove the slash to normalise the pattern
if (this.Pattern.EndsWith('/'))
{
this.PatternFlags |= PatternFlags.DIRECTORY;
this.Pattern = this.Pattern[..^1];
}

this.wildcardIndex = this.Pattern.IndexOfAny(_wildcardChars);

var rxPattern = Matcher.ToRegex(this.Pattern);

this.Pattern = this.Pattern.Replace("\\ ", " ");

// If rxPattern is null, an invalid pattern was passed to ToRegex, so it cannot match
if (!string.IsNullOrEmpty(rxPattern))
{
var rxOptions = RegexOptions.Compiled;

this.regex = new Regex(rxPattern, rxOptions);
}
}

public bool IsMatch(string path, bool pathIsDirectory)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path cannot be null or empty", nameof(path));
}

// .gitignore files use Unix paths (with a forward slash separator),
// so make sure our input also uses forward slashes
path = path.NormalisePath().TrimStart('/');

// Shortcut return if the pattern is directory-only and the path isn't a directory
// This has to be determined by the OS (at least that's the only reliable way),
// so we pass that information in as a boolean so the consuming code can provide it
if ((this.PatternFlags & PatternFlags.DIRECTORY) != 0 && !pathIsDirectory)
{
return false;
}

// If the pattern is an absolute path pattern, the path must start with the part of the pattern
// before any wildcards occur. If it doesn't, we can just return a negative match
var patternBeforeFirstWildcard =
this.wildcardIndex != -1 ? this.Pattern[..this.wildcardIndex] : this.Pattern;

if (
(this.PatternFlags & PatternFlags.ABSOLUTE_PATH) != 0
&& !path.StartsWith(patternBeforeFirstWildcard, this.stringComparison)
)
{
return false;
}

// If we got this far, we can't figure out the match with simple
// string matching, so use our regex match function

// If the *pattern* does not contain any slashes, it should match *any*
// occurence, *anywhere* within the path (e.g. '*.jpg' should match
// 'a.jpg', 'a/b.jpg', 'a/b/c.jpg'), so try matching before each slash
if (
(this.PatternFlags & PatternFlags.ABSOLUTE_PATH) == 0
&& !this.Pattern.Contains('/')
&& path.Contains('/')
)
{
return path.Split('/').Any(segment => Matcher.TryMatch(this.regex, segment));
}

// If the *path* doesn't contain any slashes, we should skip over the conditional above
return Matcher.TryMatch(this.regex, path);
}

public override string ToString()
{
return this.Pattern;
}
}
Loading