aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNiels van Velzen <nielsvanvelzen@users.noreply.github.com>2026-05-04 17:59:48 +0200
committerGitHub <noreply@github.com>2026-05-04 17:59:48 +0200
commitd359d2f7a865329ba10915dbd68dc1ab54450ffa (patch)
tree03b52cf498f704668b74ab5de143f9b261c72db0
parentba268cc3fbb9b2cde2bfb3e5d6074474b6904a0f (diff)
parent0f6bab03eb29030bbe0f00ed32ad72f97e321e62 (diff)
Merge pull request #16166 from Shadowghost/ignore-caching
Implement ignore rule caching
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs1
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs8
-rw-r--r--Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs273
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs19
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs4
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs7
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs392
8 files changed, 665 insertions, 41 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index e8cab6ea8c..b7aa2f3d06 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -536,6 +536,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IMusicManager, MusicManager>();
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
+ serviceCollection.AddSingleton<DotIgnoreIgnoreRule>();
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index 23bd5cf200..1bf0f8c76c 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.IO
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
+ private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
/// <summary>
/// The file system watchers.
@@ -47,17 +48,20 @@ namespace Emby.Server.Implementations.IO
/// <param name="configurationManager">The configuration manager.</param>
/// <param name="fileSystem">The filesystem.</param>
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
+ /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
public LibraryMonitor(
ILogger<LibraryMonitor> logger,
ILibraryManager libraryManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem,
- IHostApplicationLifetime appLifetime)
+ IHostApplicationLifetime appLifetime,
+ DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
_libraryManager = libraryManager;
_logger = logger;
_configurationManager = configurationManager;
_fileSystem = fileSystem;
+ _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start);
appLifetime.ApplicationStopping.Register(Stop);
@@ -354,7 +358,7 @@ namespace Emby.Server.Implementations.IO
}
var fileInfo = _fileSystem.GetFileSystemInfo(path);
- if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
+ if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null))
{
return;
}
diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
index ef5d24c70f..023c1e8915 100644
--- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
+++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
+using BitFaster.Caching.Lru;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers;
@@ -15,22 +17,36 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{
private static readonly bool IsWindows = OperatingSystem.IsWindows();
- private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
- {
- for (var current = directory; current is not null; current = current.Parent)
- {
- var ignorePath = Path.Join(current.FullName, ".ignore");
- if (File.Exists(ignorePath))
- {
- return new FileInfo(ignorePath);
- }
- }
+ private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache;
+ private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache;
- return null;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class.
+ /// </summary>
+ public DotIgnoreIgnoreRule()
+ {
+ var cacheSize = Math.Max(100, Environment.ProcessorCount * 100);
+ _directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>(
+ Environment.ProcessorCount,
+ cacheSize,
+ StringComparer.Ordinal);
+ _rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>(
+ Environment.ProcessorCount,
+ Math.Max(32, cacheSize / 4),
+ StringComparer.Ordinal);
}
/// <inheritdoc />
- public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
+ public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent);
+
+ /// <summary>
+ /// Clears the directory lookup cache. The parsed rules cache is not cleared
+ /// as it validates file modification time on each access.
+ /// </summary>
+ public void ClearDirectoryCache()
+ {
+ _directoryCache.Clear();
+ }
/// <summary>
/// Checks whether or not the file is ignored.
@@ -38,40 +54,38 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
/// <param name="fileInfo">The file information.</param>
/// <param name="parent">The parent BaseItem.</param>
/// <returns>True if the file should be ignored.</returns>
- public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
+ public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent)
{
var searchDirectory = fileInfo.IsDirectory
- ? new DirectoryInfo(fileInfo.FullName)
- : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
+ ? fileInfo.FullName
+ : Path.GetDirectoryName(fileInfo.FullName);
- if (string.IsNullOrEmpty(searchDirectory.FullName))
+ if (string.IsNullOrEmpty(searchDirectory))
{
return false;
}
- var ignoreFile = FindIgnoreFile(searchDirectory);
+ var ignoreFile = FindIgnoreFileCached(searchDirectory);
if (ignoreFile is null)
{
return false;
}
- // Fast path in case the ignore files isn't a symlink and is empty
- if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
+ var parsedEntry = GetParsedRules(ignoreFile);
+ if (parsedEntry is null)
{
- // Ignore directory if we just have the file
- return true;
+ // File was deleted after we cached the path - clear the directory cache entry and return false
+ _directoryCache.TryRemove(searchDirectory, out _);
+ return false;
}
- var content = GetFileContent(ignoreFile);
- return string.IsNullOrWhiteSpace(content)
- || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
- }
+ // Empty file means ignore everything
+ if (parsedEntry.IsEmpty)
+ {
+ return true;
+ }
- private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
- {
- // If file has content, base ignoring off the content .gitignore-style rules
- var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- return CheckIgnoreRules(path, rules, isDirectory);
+ return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory));
}
/// <summary>
@@ -117,8 +131,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
return true;
}
- // Mitigate the problem of the Ignore library not handling Windows paths correctly.
- // See https://github.com/jellyfin/jellyfin/issues/15484
+ // Mitigate the problem of the Ignore library not handling Windows paths correctly.
+ // See https://github.com/jellyfin/jellyfin/issues/15484
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
// Add trailing slash for directories to match "folder/"
@@ -130,11 +144,196 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
return ignore.IsIgnored(pathToCheck);
}
- private static string GetFileContent(FileInfo ignoreFile)
+ private FileInfo? FindIgnoreFileCached(string directory)
+ {
+ // Check if we have a cached result for this directory
+ if (_directoryCache.TryGet(directory, out var cached))
+ {
+ return cached.IgnoreFileDirectory is null
+ ? null
+ : new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore"));
+ }
+
+ DirectoryInfo startDir;
+ try
+ {
+ startDir = new DirectoryInfo(directory);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+
+ // Walk up the directory tree to find .ignore file using DirectoryInfo.Parent
+ var checkedDirs = new List<string> { directory };
+
+ for (var current = startDir; current is not null; current = current.Parent)
+ {
+ var currentPath = current.FullName;
+
+ // Check if this intermediate directory is cached
+ if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached))
+ {
+ // Cache the result for all directories we checked
+ var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory);
+ foreach (var dir in checkedDirs)
+ {
+ _directoryCache.AddOrUpdate(dir, entry);
+ }
+
+ return parentCached.IgnoreFileDirectory is null
+ ? null
+ : new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore"));
+ }
+
+ var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore"));
+ if (ignoreFile.Exists)
+ {
+ // Cache for all directories we checked
+ var entry = new IgnoreFileCacheEntry(currentPath);
+ foreach (var dir in checkedDirs)
+ {
+ _directoryCache.AddOrUpdate(dir, entry);
+ }
+
+ return ignoreFile;
+ }
+
+ if (current != startDir)
+ {
+ checkedDirs.Add(currentPath);
+ }
+ }
+
+ // No .ignore file found - cache null result for all directories
+ var nullEntry = new IgnoreFileCacheEntry((string?)null);
+ foreach (var dir in checkedDirs)
+ {
+ _directoryCache.AddOrUpdate(dir, nullEntry);
+ }
+
+ return null;
+ }
+
+ private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile)
+ {
+ if (!ignoreFile.Exists)
+ {
+ _rulesCache.TryRemove(ignoreFile.FullName, out _);
+ return null;
+ }
+
+ var lastModified = ignoreFile.LastWriteTimeUtc;
+ var fileLength = ignoreFile.Length;
+ var key = ignoreFile.FullName;
+
+ // Check cache
+ if (_rulesCache.TryGet(key, out var cached))
+ {
+ if (cached.FileLastModified == lastModified && cached.FileLength == fileLength)
+ {
+ return cached;
+ }
+
+ // Stale - need to reparse
+ _rulesCache.TryRemove(key, out _);
+ }
+
+ // Parse the file
+ var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength);
+ _rulesCache.AddOrUpdate(key, parsedEntry);
+ return parsedEntry;
+ }
+
+ private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength)
{
- ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
- return ignoreFile.Exists
- ? File.ReadAllText(ignoreFile.FullName)
- : string.Empty;
+ if (ignoreFile.LinkTarget is null && fileLength == 0)
+ {
+ return new ParsedIgnoreCacheEntry
+ {
+ Rules = new Ignore.Ignore(),
+ FileLastModified = lastModified,
+ FileLength = fileLength,
+ IsEmpty = true
+ };
+ }
+
+ // Resolve symlinks
+ var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
+ if (!resolvedFile.Exists)
+ {
+ return new ParsedIgnoreCacheEntry
+ {
+ Rules = new Ignore.Ignore(),
+ FileLastModified = lastModified,
+ FileLength = fileLength,
+ IsEmpty = true
+ };
+ }
+
+ var content = File.ReadAllText(resolvedFile.FullName);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ return new ParsedIgnoreCacheEntry
+ {
+ Rules = new Ignore.Ignore(),
+ FileLastModified = lastModified,
+ FileLength = fileLength,
+ IsEmpty = true
+ };
+ }
+
+ var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var ignore = new Ignore.Ignore();
+ var validRulesAdded = 0;
+
+ foreach (var rule in rules)
+ {
+ try
+ {
+ ignore.Add(rule);
+ validRulesAdded++;
+ }
+ catch (RegexParseException)
+ {
+ // Ignore invalid patterns
+ }
+ }
+
+ // No valid rules means treat as empty (ignore all)
+ return new ParsedIgnoreCacheEntry
+ {
+ Rules = ignore,
+ FileLastModified = lastModified,
+ FileLength = fileLength,
+ IsEmpty = validRulesAdded == 0
+ };
+ }
+
+ private static string GetPathToCheck(string path, bool isDirectory)
+ {
+ // Normalize Windows paths
+ var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
+
+ // Add trailing slash for directories to match "folder/"
+ if (isDirectory)
+ {
+ pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
+ }
+
+ return pathToCheck;
+ }
+
+ private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory);
+
+ private sealed class ParsedIgnoreCacheEntry
+ {
+ public required Ignore.Ignore Rules { get; init; }
+
+ public required DateTime FileLastModified { get; init; }
+
+ public required long FileLength { get; init; }
+
+ public required bool IsEmpty { get; init; }
}
}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index b7852eeb4f..11f1496086 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -86,6 +86,7 @@ namespace Emby.Server.Implementations.Library
private readonly ExtraResolver _extraResolver;
private readonly IPathManager _pathManager;
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
+ private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
/// <summary>
/// The _root folder sync lock.
@@ -127,6 +128,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="directoryService">The directory service.</param>
/// <param name="peopleRepository">The people repository.</param>
/// <param name="pathManager">The path manager.</param>
+ /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
public LibraryManager(
IServerApplicationHost appHost,
ILoggerFactory loggerFactory,
@@ -148,7 +150,8 @@ namespace Emby.Server.Implementations.Library
NamingOptions namingOptions,
IDirectoryService directoryService,
IPeopleRepository peopleRepository,
- IPathManager pathManager)
+ IPathManager pathManager,
+ DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -173,6 +176,7 @@ namespace Emby.Server.Implementations.Library
_namingOptions = namingOptions;
_peopleRepository = peopleRepository;
_pathManager = pathManager;
+ _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@@ -1305,6 +1309,7 @@ namespace Emby.Server.Implementations.Library
public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken)
{
IsScanRunning = true;
+ ClearIgnoreRuleCache();
LibraryMonitor.Stop();
try
@@ -1313,6 +1318,7 @@ namespace Emby.Server.Implementations.Library
}
finally
{
+ ClearIgnoreRuleCache();
LibraryMonitor.Start();
IsScanRunning = false;
}
@@ -1320,6 +1326,7 @@ namespace Emby.Server.Implementations.Library
public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
{
+ ClearIgnoreRuleCache();
RootFolder.Children = null;
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
@@ -1362,6 +1369,14 @@ namespace Emby.Server.Implementations.Library
{
_persistenceService.DeleteItem(toDelete.ToArray());
}
+
+ ClearIgnoreRuleCache();
+ }
+
+ /// <inheritdoc />
+ public void ClearIgnoreRuleCache()
+ {
+ _dotIgnoreIgnoreRule.ClearDirectoryCache();
}
private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
@@ -3182,7 +3197,7 @@ namespace Emby.Server.Implementations.Library
public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
{
// Apply .ignore rules
- var filtered = fileSystemChildren.Where(c => !DotIgnoreIgnoreRule.IsIgnored(c, owner)).ToList();
+ var filtered = fileSystemChildren.Where(c => !_dotIgnoreIgnoreRule.ShouldIgnore(c, owner)).ToList();
var isFolder = owner.IsFolder || (owner is Video video && (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd));
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
if (ownerVideoInfo is null)
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index 8136dec177..e46795554b 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -189,6 +189,7 @@ public class LibraryStructureController : BaseJellyfinApiController
var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase));
if (newLib is CollectionFolder folder)
{
+ _libraryManager.ClearIgnoreRuleCache();
foreach (var child in folder.GetPhysicalFolders())
{
await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
@@ -197,9 +198,12 @@ public class LibraryStructureController : BaseJellyfinApiController
}
else
{
+ _libraryManager.ClearIgnoreRuleCache();
// We don't know if this one can be validated individually, trigger a new validation
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
}
+
+ _libraryManager.ClearIgnoreRuleCache();
}
else
{
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 7d27572052..f5e3d7034e 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -177,6 +177,13 @@ namespace MediaBrowser.Controller.Library
/// <returns>Task.</returns>
Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false);
+ /// <summary>
+ /// Clears the cached ignore rule directory lookups.
+ /// Call this before triggering a library scan or item refresh to ensure
+ /// any changes to .ignore files are picked up.
+ /// </summary>
+ void ClearIgnoreRuleCache();
+
Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false);
/// <summary>
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index d57e85c62f..65edcb2a92 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -1133,6 +1133,7 @@ namespace MediaBrowser.Providers.Manager
var cancellationToken = _disposeCancellationTokenSource.Token;
+ libraryManager.ClearIgnoreRuleCache();
while (_refreshQueue.TryDequeue(out var refreshItem, out _))
{
if (_disposed)
@@ -1167,6 +1168,7 @@ namespace MediaBrowser.Providers.Manager
lock (_refreshQueueLock)
{
_isProcessingRefreshQueue = false;
+ libraryManager.ClearIgnoreRuleCache();
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
index a7bbef7ed4..03c0b4af39 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
@@ -1,4 +1,9 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
using Emby.Server.Implementations.Library;
+using MediaBrowser.Model.IO;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library;
@@ -78,4 +83,391 @@ public class DotIgnoreIgnoreRuleTest
// Without normalization, Windows paths with backslashes won't match patterns expecting forward slashes
Assert.False(DotIgnoreIgnoreRule.CheckIgnoreRules(path, _rule1, isDirectory: false, normalizePath: false));
}
+
+ [Fact]
+ public void CacheHit_RepeatedCallsDoNotRereadFiles()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var subDir = Path.Combine(tempDir, "subdir");
+ Directory.CreateDirectory(subDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call - should cache
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Second call - should use cache
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result2);
+
+ // Third call with different file in same directory - should use cache
+ var fileInfo2 = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir, "other.tmp"),
+ IsDirectory = false
+ };
+ var result3 = rule.ShouldIgnore(fileInfo2, null);
+ Assert.True(result3);
+
+ // Call with file that doesn't match pattern
+ var fileInfo3 = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir, "other.txt"),
+ IsDirectory = false
+ };
+ var result4 = rule.ShouldIgnore(fileInfo3, null);
+ Assert.False(result4);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void CacheInvalidation_ModifyIgnoreFile_Reparses()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call - should ignore .tmp files
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Modify the .ignore file to ignore .txt instead
+ // Wait a bit to ensure the file modification time changes
+ Thread.Sleep(50);
+ File.WriteAllText(ignoreFilePath, "*.txt");
+
+ // Now .tmp files should NOT be ignored
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result2);
+
+ // And .txt files SHOULD be ignored
+ var txtFileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.txt"),
+ IsDirectory = false
+ };
+ var result3 = rule.ShouldIgnore(txtFileInfo, null);
+ Assert.True(result3);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void EmptyIgnoreFile_IgnoresEverything()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, string.Empty);
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "anyfile.mkv"),
+ IsDirectory = false
+ };
+
+ // Empty .ignore file should ignore everything
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void WhitespaceOnlyIgnoreFile_IgnoresEverything()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, " \n\t\n ");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "anyfile.mkv"),
+ IsDirectory = false
+ };
+
+ // Whitespace-only .ignore file should ignore everything
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void NoIgnoreFile_DoesNotIgnore()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var rule = new DotIgnoreIgnoreRule();
+
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "anyfile.mkv"),
+ IsDirectory = false
+ };
+
+ // No .ignore file means don't ignore
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void ConcurrentAccess_ThreadSafe()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ // Run multiple parallel checks
+ Parallel.For(0, 100, i =>
+ {
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, $"test{i}.tmp"),
+ IsDirectory = false
+ };
+
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+ });
+
+ // Also test with non-matching files
+ Parallel.For(0, 100, i =>
+ {
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, $"test{i}.txt"),
+ IsDirectory = false
+ };
+
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result);
+ });
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void ClearCache_ClearsAllCachedData()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call to populate cache
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Clear cache
+ rule.ClearDirectoryCache();
+
+ // Should still work (will re-populate cache)
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result2);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void IgnoreFileDeleted_HandlesGracefully()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "test.tmp"),
+ IsDirectory = false
+ };
+
+ // First call - should ignore
+ var result1 = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result1);
+
+ // Delete the .ignore file
+ File.Delete(ignoreFilePath);
+
+ // Should not ignore anymore (file deleted)
+ var result2 = rule.ShouldIgnore(fileInfo, null);
+ Assert.False(result2);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Fact]
+ public void ParentDirectoryIgnoreFile_AppliesToSubdirectories()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var subDir1 = Path.Combine(tempDir, "sub1");
+ var subDir2 = Path.Combine(tempDir, "sub1", "sub2");
+ Directory.CreateDirectory(subDir1);
+ Directory.CreateDirectory(subDir2);
+
+ try
+ {
+ // Put .ignore in root
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "*.tmp");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ // Check file in sub2 - should find .ignore in parent
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir2, "test.tmp"),
+ IsDirectory = false
+ };
+
+ var result = rule.ShouldIgnore(fileInfo, null);
+ Assert.True(result);
+
+ // Check file in sub1
+ var fileInfo2 = new FileSystemMetadata
+ {
+ FullName = Path.Combine(subDir1, "test.tmp"),
+ IsDirectory = false
+ };
+
+ var result2 = rule.ShouldIgnore(fileInfo2, null);
+ Assert.True(result2);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DirectoryMatching_TrailingSlashPattern()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var subDir = Path.Combine(tempDir, "videos");
+ Directory.CreateDirectory(subDir);
+
+ try
+ {
+ var ignoreFilePath = Path.Combine(tempDir, ".ignore");
+ File.WriteAllText(ignoreFilePath, "videos/");
+
+ var rule = new DotIgnoreIgnoreRule();
+
+ // Directory should be ignored
+ var dirInfo = new FileSystemMetadata
+ {
+ FullName = subDir,
+ IsDirectory = true
+ };
+
+ var result = rule.ShouldIgnore(dirInfo, null);
+ Assert.True(result);
+
+ // File named "videos" should NOT be ignored (pattern has trailing slash)
+ var fileInfo = new FileSystemMetadata
+ {
+ FullName = Path.Combine(tempDir, "videos"),
+ IsDirectory = false
+ };
+
+ // Note: The Ignore library behavior may vary here, this tests the actual behavior
+ var resultFile = rule.ShouldIgnore(fileInfo, null);
+ // The file named "videos" without trailing slash might or might not match depending on the library
+ // This test documents the actual behavior
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
}